@stim-cli/server 1.14.0 → 1.16.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 (52) hide show
  1. package/README.md +1773 -92
  2. package/dist/protocol-BHSl7bQ_.mjs +2772 -0
  3. package/dist/protocol.d.mts +2 -496
  4. package/dist/protocol.mjs +3 -2
  5. package/dist/protocol.schema.json +5388 -684
  6. package/dist/scrcpy/LICENSE +204 -0
  7. package/dist/scrcpy/NOTICE +22 -0
  8. package/dist/scrcpy/scrcpy-server +0 -0
  9. package/dist/stats-read.d.mts +1 -0
  10. package/dist/stats-read.mjs +46 -0
  11. package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
  12. package/dist/stim-frames/DevToolsClient.swift +180 -0
  13. package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
  14. package/dist/stim-frames/DeviceFrameView.swift +167 -0
  15. package/dist/stim-frames/DuoCaptureSource.swift +132 -0
  16. package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
  17. package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
  18. package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
  19. package/dist/stim-frames/DuoMesh.swift +100 -0
  20. package/dist/stim-frames/DuoModelView.swift +275 -0
  21. package/dist/stim-frames/DuoPoseClip.swift +140 -0
  22. package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
  23. package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
  24. package/dist/stim-frames/EmulatorInput.swift +13 -2
  25. package/dist/stim-frames/EmulatorPosture.swift +2 -2
  26. package/dist/stim-frames/FrameArtwork.swift +37 -0
  27. package/dist/stim-frames/H264Decoder.swift +109 -0
  28. package/dist/stim-frames/InputMessages.swift +27 -6
  29. package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
  30. package/dist/stim-frames/MacosSource.swift +677 -0
  31. package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
  32. package/dist/stim-frames/OwnedAppWindows.swift +108 -0
  33. package/dist/stim-frames/PhoneSource.swift +315 -0
  34. package/dist/stim-frames/ProcessStream.swift +155 -0
  35. package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
  36. package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
  37. package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
  38. package/dist/stim-frames/SimulatorHID.swift +70 -29
  39. package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
  40. package/dist/stim-frames/SimulatorPosture.swift +277 -0
  41. package/dist/stim-frames/SimulatorRotation.swift +28 -8
  42. package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
  43. package/dist/stim-frames/StoragePaths.swift +42 -0
  44. package/dist/stim-frames/VideoEncoder.swift +8 -6
  45. package/dist/stim-frames/WebPage.swift +301 -0
  46. package/dist/stim-frames/main.swift +651 -22
  47. package/dist/stim-host/Info.plist +28 -0
  48. package/dist/stim-host/stim-host.c +153 -0
  49. package/dist/stim-server-CvqlgkBK.mjs +14887 -0
  50. package/dist/stim-server.mjs +6 -4421
  51. package/package.json +3 -3
  52. package/dist/protocol-IroIg8Td.mjs +0 -865
@@ -0,0 +1,89 @@
1
+ import Darwin
2
+ import Foundation
3
+
4
+ enum DuoHingeMonitor {
5
+ static func run(udid: String) -> Never {
6
+ let parent = getppid()
7
+ guard parent > 1 else { exit(1) }
8
+ let queue = DispatchQueue(label: "stim.frames.hinge-monitor")
9
+ var child: pid_t = 0
10
+ var stopping = false
11
+ let stop = {
12
+ guard !stopping else { return }
13
+ stopping = true
14
+ guard child > 0 else { exit(0) }
15
+ kill(child, SIGINT)
16
+ queue.asyncAfter(deadline: .now() + 1) {
17
+ if child > 0 { kill(child, SIGKILL) }
18
+ }
19
+ }
20
+ let parentExit = DispatchSource.makeProcessSource(identifier: parent, eventMask: .exit, queue: queue)
21
+ parentExit.setEventHandler(handler: stop)
22
+ parentExit.resume()
23
+ let reap = DispatchSource.makeTimerSource(queue: queue)
24
+ reap.schedule(deadline: .now(), repeating: .milliseconds(100))
25
+ reap.setEventHandler {
26
+ guard child > 0 else { return }
27
+ var status: Int32 = 0
28
+ if waitpid(child, &status, WNOHANG) == child {
29
+ child = 0
30
+ exit(0)
31
+ }
32
+ }
33
+ reap.resume()
34
+ var signals: [DispatchSourceSignal] = []
35
+ for value in [SIGTERM, SIGINT] {
36
+ signal(value, SIG_IGN)
37
+ let source = DispatchSource.makeSignalSource(signal: value, queue: queue)
38
+ source.setEventHandler(handler: stop)
39
+ source.resume()
40
+ signals.append(source)
41
+ }
42
+ Thread.detachNewThread {
43
+ _ = FileHandle.standardInput.readDataToEndOfFile()
44
+ queue.async(execute: stop)
45
+ }
46
+ queue.asyncAfter(deadline: .now() + 60, execute: stop)
47
+ queue.async {
48
+ guard !stopping, getppid() == parent else { return stop() }
49
+ let arguments = [
50
+ "/usr/bin/xcrun", "devicectl", "device", "motion", "hinge-angle", "--device", udid,
51
+ "--session-timeout", "55", "--timeout", "60", "--change-threshold", "1", "--update-interval", "0.5",
52
+ ]
53
+ var environment = ProcessInfo.processInfo.environment
54
+ environment["DEVELOPER_DIR"] = CoreSimulator.developerDir
55
+ environment["LC_ALL"] = "en_US.UTF-8"
56
+ let argv = arguments.map { strdup($0) } + [nil]
57
+ let env = environment.map { strdup("\($0.key)=\($0.value)") } + [nil]
58
+ defer {
59
+ for pointer in argv + env { free(pointer) }
60
+ }
61
+ var actions: posix_spawn_file_actions_t?
62
+ guard posix_spawn_file_actions_init(&actions) == 0 else { exit(1) }
63
+ defer { posix_spawn_file_actions_destroy(&actions) }
64
+ guard posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0) == 0,
65
+ posix_spawn_file_actions_addopen(&actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0) == 0
66
+ else { exit(1) }
67
+ var attributes: posix_spawnattr_t?
68
+ guard posix_spawnattr_init(&attributes) == 0 else { exit(1) }
69
+ defer { posix_spawnattr_destroy(&attributes) }
70
+ var defaults = sigset_t()
71
+ sigemptyset(&defaults)
72
+ sigaddset(&defaults, SIGINT)
73
+ sigaddset(&defaults, SIGTERM)
74
+ var mask = sigset_t()
75
+ sigemptyset(&mask)
76
+ guard posix_spawnattr_setsigdefault(&attributes, &defaults) == 0,
77
+ posix_spawnattr_setsigmask(&attributes, &mask) == 0,
78
+ posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK)) == 0
79
+ else { exit(1) }
80
+ let result = argv.withUnsafeBufferPointer { argv in
81
+ env.withUnsafeBufferPointer { env in
82
+ posix_spawn(&child, "/usr/bin/xcrun", &actions, &attributes, argv.baseAddress!, env.baseAddress!)
83
+ }
84
+ }
85
+ guard result == 0 else { exit(1) }
86
+ }
87
+ withExtendedLifetime((parentExit, reap, signals)) { dispatchMain() }
88
+ }
89
+ }
@@ -0,0 +1,100 @@
1
+ // Copyright (c) 2026 Siniulator contributors. Adapted under the MIT license in Support/Siniulator-LICENSE.txt.
2
+ import SceneKit
3
+ import simd
4
+
5
+ @MainActor struct DuoMesh {
6
+ let node: SCNNode
7
+ let vertices: [SIMD4<Float>]
8
+ let bones: [SCNNode]
9
+ let binds: [simd_float4x4]
10
+ let influences: [[(index: Int, weight: Float)]]
11
+
12
+ init?(node: SCNNode) {
13
+ guard let source = node.geometry?.sources(for: .vertex).first,
14
+ source.componentsPerVector == 3, let values = Self.components(source),
15
+ values.allSatisfy({ Float($0).isFinite })
16
+ else { return nil }
17
+ self.node = node
18
+ vertices = stride(from: 0, to: values.count, by: 3).map {
19
+ SIMD4(Float(values[$0]), Float(values[$0 + 1]), Float(values[$0 + 2]), 1)
20
+ }
21
+ guard let skin = node.skinner else {
22
+ bones = []
23
+ binds = []
24
+ influences = []
25
+ return
26
+ }
27
+ bones = skin.bones
28
+ let bind = simd_float4x4(skin.baseGeometryBindTransform)
29
+ binds =
30
+ (skin.boneInverseBindTransforms?.map { simd_float4x4($0.scnMatrix4Value) }
31
+ ?? Array(repeating: matrix_identity_float4x4, count: bones.count)).map { $0 * bind }
32
+ guard !bones.isEmpty, binds.count == bones.count else { return nil }
33
+ if skin.boneWeights.vectorCount == 0, skin.boneIndices.vectorCount == 0 {
34
+ guard bones.count == 1 else { return nil }
35
+ influences = vertices.map { _ in [(0, 1)] }
36
+ return
37
+ }
38
+ guard skin.boneWeights.vectorCount == vertices.count,
39
+ skin.boneIndices.vectorCount == vertices.count,
40
+ skin.boneWeights.componentsPerVector == skin.boneIndices.componentsPerVector,
41
+ let indices = Self.components(skin.boneIndices),
42
+ let weights = Self.components(skin.boneWeights), indices.count == weights.count,
43
+ indices.allSatisfy({ $0.isFinite && $0 >= 0 && $0 < Double(skin.bones.count) && $0.rounded() == $0 }),
44
+ weights.allSatisfy({ Float($0).isFinite && $0 >= 0 })
45
+ else { return nil }
46
+ let count = skin.boneIndices.componentsPerVector
47
+ influences = vertices.indices.map { vertex in
48
+ (0..<count).compactMap { offset in
49
+ let index = vertex * count + offset
50
+ return weights[index] > 0 ? (Int(indices[index]), Float(weights[index])) : nil
51
+ }
52
+ }
53
+ guard influences.allSatisfy({ !$0.isEmpty }) else { return nil }
54
+ }
55
+
56
+ var transforms: [simd_float4x4] {
57
+ bones.isEmpty
58
+ ? [node.simdWorldTransform]
59
+ : bones.indices.map {
60
+ bones[$0].simdWorldTransform * binds[$0]
61
+ }
62
+ }
63
+
64
+ func position(at index: Int, using transforms: [simd_float4x4]) -> SIMD4<Float> {
65
+ let vertex = vertices[index]
66
+ if influences.isEmpty { return transforms[0] * vertex }
67
+ return influences[index].reduce(SIMD4<Float>.zero) {
68
+ $0 + transforms[$1.index] * vertex * $1.weight
69
+ }
70
+ }
71
+
72
+ static func components(_ source: SCNGeometrySource) -> [Double]? {
73
+ let size = source.bytesPerComponent
74
+ guard source.vectorCount > 0, source.componentsPerVector > 0,
75
+ source.dataOffset >= 0, source.dataStride >= source.componentsPerVector * size,
76
+ [1, 2, 4, 8].contains(size),
77
+ !source.usesFloatComponents || [4, 8].contains(size),
78
+ source.dataOffset + (source.vectorCount - 1) * source.dataStride
79
+ + source.componentsPerVector * size <= source.data.count
80
+ else { return nil }
81
+ return source.data.withUnsafeBytes { data in
82
+ (0..<source.vectorCount).flatMap { vector in
83
+ (0..<source.componentsPerVector).map { component -> Double in
84
+ let offset = source.dataOffset + vector * source.dataStride + component * size
85
+ if source.usesFloatComponents {
86
+ return size == 4
87
+ ? Double(data.loadUnaligned(fromByteOffset: offset, as: Float.self))
88
+ : data.loadUnaligned(fromByteOffset: offset, as: Double.self)
89
+ }
90
+ switch size {
91
+ case 1: return Double(data.loadUnaligned(fromByteOffset: offset, as: UInt8.self))
92
+ case 2: return Double(data.loadUnaligned(fromByteOffset: offset, as: UInt16.self))
93
+ case 4: return Double(data.loadUnaligned(fromByteOffset: offset, as: UInt32.self))
94
+ default: return Double(data.loadUnaligned(fromByteOffset: offset, as: UInt64.self))
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,275 @@
1
+ // Copyright (c) 2026 Siniulator contributors. Adapted under the MIT license in Support/Siniulator-LICENSE.txt.
2
+ import AppKit
3
+ import IOSurface
4
+ import Metal
5
+ import SceneKit
6
+
7
+ @MainActor
8
+ final class DuoModelView: SCNView {
9
+ private let clip: DuoPoseClip
10
+ private let cameraNode = SCNNode()
11
+ private let inner: SCNNode
12
+ private let cover: SCNNode
13
+ private let innerHits: DuoScreenHitMesh
14
+ private let coverHits: DuoScreenHitMesh
15
+ private let metal: MTLDevice
16
+ private var textures: [UInt32: MTLTexture] = [:]
17
+ private var surfaces: [UInt32: IOSurface] = [:]
18
+ private var frozen: [UInt32: MTLTexture] = [:]
19
+ private var pendingSnapshot: (id: UInt32, texture: MTLTexture)?
20
+ let innerID: UInt32
21
+ let coverID: UInt32
22
+ private let nativeTurns: [UInt32: Int]
23
+ private var angle: CGFloat = 180
24
+ private var quarterTurns = 0
25
+ var activeID: UInt32
26
+
27
+ static var assetURL: URL {
28
+ URL(fileURLWithPath: CoreSimulator.developerDir)
29
+ .deletingLastPathComponent()
30
+ .appendingPathComponent(
31
+ "SharedFrameworks/DeviceKit.framework/Versions/A/PlugIns/CoreDevicePopDeviceKitExtension.devicekitplugin/Contents/Resources/V68.usdz"
32
+ )
33
+ }
34
+
35
+ static func load(innerID: UInt32, coverID: UInt32, nativeTurns: [UInt32: Int], assetURL: URL? = nil) -> DuoModelView? {
36
+ guard let source = SCNSceneSource(url: assetURL ?? Self.assetURL, options: nil),
37
+ let scene = source.scene(options: [.animationImportPolicy: SCNSceneSource.AnimationImportPolicy.play]),
38
+ let inner = scene.rootNode.childNode(withName: "mQHVkATpIwJRVQx", recursively: true),
39
+ let cover = scene.rootNode.childNode(withName: "zaWsadDZpWAUDAX", recursively: true),
40
+ let clip = DuoPoseClip(root: scene.rootNode),
41
+ let innerHits = DuoScreenHitMesh(node: inner),
42
+ let coverHits = DuoScreenHitMesh(node: cover),
43
+ let metal = MTLCreateSystemDefaultDevice()
44
+ else { return nil }
45
+ return DuoModelView(
46
+ scene: scene, inner: inner, cover: cover, clip: clip,
47
+ innerHits: innerHits, coverHits: coverHits, metal: metal, innerID: innerID, coverID: coverID, nativeTurns: nativeTurns)
48
+ }
49
+
50
+ private init(
51
+ scene: SCNScene, inner: SCNNode, cover: SCNNode, clip: DuoPoseClip,
52
+ innerHits: DuoScreenHitMesh, coverHits: DuoScreenHitMesh, metal: MTLDevice,
53
+ innerID: UInt32, coverID: UInt32, nativeTurns: [UInt32: Int]
54
+ ) {
55
+ self.inner = inner
56
+ self.cover = cover
57
+ self.clip = clip
58
+ self.innerHits = innerHits
59
+ self.coverHits = coverHits
60
+ self.metal = metal
61
+ self.innerID = innerID
62
+ self.coverID = coverID
63
+ activeID = innerID
64
+ self.nativeTurns = nativeTurns
65
+ super.init(frame: .zero, options: [SCNView.Option.preferredRenderingAPI.rawValue: SCNRenderingAPI.metal.rawValue])
66
+ self.scene = scene
67
+ scene.rootNode.addChildNode(cameraNode)
68
+ let camera = SCNCamera()
69
+ camera.fieldOfView = 31
70
+ camera.zNear = 0.01
71
+ camera.zFar = 200
72
+ camera.wantsHDR = false
73
+ cameraNode.camera = camera
74
+ pointOfView = cameraNode
75
+ backgroundColor = .clear
76
+ wantsLayer = true
77
+ layer?.backgroundColor = NSColor.clear.cgColor
78
+ antialiasingMode = .multisampling4X
79
+ allowsCameraControl = false
80
+ rendersContinuously = false
81
+ isPlaying = false
82
+ prepare(inner)
83
+ prepare(cover)
84
+ let ambient = SCNNode()
85
+ ambient.light = SCNLight()
86
+ ambient.light?.type = .ambient
87
+ ambient.light?.color = NSColor(white: 0.5, alpha: 1)
88
+ scene.rootNode.addChildNode(ambient)
89
+ let key = SCNNode()
90
+ key.light = SCNLight()
91
+ key.light?.type = .omni
92
+ key.light?.intensity = 900
93
+ key.position = SCNVector3(-4, 24, -8)
94
+ scene.rootNode.addChildNode(key)
95
+ setPose(angle: 180, orientation: 1, activeID: innerID)
96
+ }
97
+
98
+ required init?(coder: NSCoder) { nil }
99
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
100
+
101
+ func updateSurface(_ surface: IOSurface?, screenID: UInt32) {
102
+ guard let surface else {
103
+ surfaces[screenID] = nil
104
+ textures[screenID] = nil
105
+ if frozen[screenID] == nil {
106
+ for material in node(screenID).geometry?.materials ?? [] { material.diffuse.contents = nil }
107
+ }
108
+ needsDisplay = true
109
+ return
110
+ }
111
+ let changed = surfaces[screenID] !== surface
112
+ surfaces[screenID] = surface
113
+ guard frozen[screenID] == nil else { return }
114
+ if changed || textures[screenID] == nil {
115
+ textures[screenID] = texture(surface)
116
+ }
117
+ let matrix = textureTransform(turns: nativeTurns[screenID] ?? 0)
118
+ for material in node(screenID).geometry?.materials ?? [] {
119
+ material.diffuse.contents = textures[screenID]
120
+ material.diffuse.contentsTransform = matrix
121
+ }
122
+ needsDisplay = true
123
+ }
124
+
125
+ func preparePanelChange() {
126
+ guard let surface = surfaces[activeID], let texture = textures[activeID] else { return }
127
+ let descriptor = MTLTextureDescriptor.texture2DDescriptor(
128
+ pixelFormat: texture.pixelFormat,
129
+ width: texture.width, height: texture.height, mipmapped: false)
130
+ descriptor.storageMode = .shared
131
+ descriptor.usage = .shaderRead
132
+ guard let snapshot = metal.makeTexture(descriptor: descriptor) else { return }
133
+ surface.lock(options: .readOnly, seed: nil)
134
+ snapshot.replace(
135
+ region: MTLRegionMake2D(0, 0, texture.width, texture.height), mipmapLevel: 0,
136
+ withBytes: surface.baseAddress, bytesPerRow: surface.bytesPerRow)
137
+ surface.unlock(options: .readOnly, seed: nil)
138
+ pendingSnapshot = (activeID, snapshot)
139
+ }
140
+
141
+ func setPose(angle: CGFloat, orientation: UInt32, activeID: UInt32) {
142
+ let nextAngle = min(180, max(0, angle))
143
+ let uiTurns = orientation == 3 ? 1 : orientation == 4 ? 3 : orientation == 2 ? 2 : 0
144
+ let nextTurns = (uiTurns - (nativeTurns[activeID] ?? 0) + 4) % 4
145
+ guard self.angle != nextAngle || quarterTurns != nextTurns || self.activeID != activeID else { return }
146
+ if self.activeID != activeID {
147
+ if let snapshot = pendingSnapshot, snapshot.id == self.activeID {
148
+ frozen[snapshot.id] = snapshot.texture
149
+ for material in node(snapshot.id).geometry?.materials ?? [] { material.diffuse.contents = snapshot.texture }
150
+ }
151
+ pendingSnapshot = nil
152
+ frozen[activeID] = nil
153
+ textures[activeID] = nil
154
+ }
155
+ self.activeID = activeID
156
+ self.angle = nextAngle
157
+ quarterTurns = nextTurns
158
+ SCNTransaction.begin()
159
+ SCNTransaction.disableActions = true
160
+ clip.apply(angle: self.angle)
161
+ updateCamera()
162
+ SCNTransaction.commit()
163
+ if let surface = surfaces[activeID] { updateSurface(surface, screenID: activeID) }
164
+ needsDisplay = true
165
+ }
166
+
167
+ override func layout() {
168
+ super.layout()
169
+ updateCamera()
170
+ }
171
+
172
+ func nativeScreenPoint(_ point: CGPoint, clamped: Bool) -> CGPoint? {
173
+ let mesh = activeID == coverID ? coverHits : innerHits
174
+ let near = unprojectPoint(SCNVector3(point.x, point.y, 0))
175
+ let far = unprojectPoint(SCNVector3(point.x, point.y, 1))
176
+ var uv = mesh.textureCoordinate(from: near, to: far)
177
+ if uv == nil, clamped {
178
+ uv = mesh.nearestTextureCoordinate(to: point, project: { self.projectPoint($0) }, unproject: { self.unprojectPoint($0) })
179
+ }
180
+ return uv.map { rotate($0, turns: nativeTurns[activeID] ?? 0) }
181
+ }
182
+
183
+ func viewPoint(_ native: CGPoint) -> CGPoint? {
184
+ let mesh = activeID == coverID ? coverHits : innerHits
185
+ let uv = rotate(native, turns: -(nativeTurns[activeID] ?? 0))
186
+ guard let world = mesh.position(at: uv) else { return nil }
187
+ let p = projectPoint(world)
188
+ return CGPoint(x: p.x, y: p.y)
189
+ }
190
+
191
+ private func node(_ id: UInt32) -> SCNNode { id == coverID ? cover : inner }
192
+
193
+ private func prepare(_ node: SCNNode) {
194
+ node.geometry?.materials = (node.geometry?.materials ?? []).map { source in
195
+ let material = source.copy() as? SCNMaterial ?? source
196
+ material.lightingModel = .constant
197
+ material.isDoubleSided = true
198
+ material.blendMode = .replace
199
+ material.transparency = 1
200
+ material.transparent.contents = nil
201
+ material.diffuse.intensity = 1
202
+ material.multiply.contents = NSColor.white
203
+ material.diffuse.wrapS = .clamp
204
+ material.diffuse.wrapT = .clamp
205
+ return material
206
+ }
207
+ }
208
+
209
+ private func texture(_ surface: IOSurface) -> MTLTexture? {
210
+ let format: MTLPixelFormat
211
+ switch surface.pixelFormat {
212
+ case 0x42475241: format = .bgra8Unorm_srgb
213
+ case 0x52474241: format = .rgba8Unorm_srgb
214
+ default: return nil
215
+ }
216
+ let descriptor = MTLTextureDescriptor.texture2DDescriptor(
217
+ pixelFormat: format,
218
+ width: surface.width, height: surface.height, mipmapped: false)
219
+ descriptor.storageMode = .shared
220
+ descriptor.usage = .shaderRead
221
+ return metal.makeTexture(descriptor: descriptor, iosurface: surface, plane: 0)
222
+ }
223
+
224
+ private func updateCamera() {
225
+ guard bounds.width > 0, bounds.height > 0 else { return }
226
+ let t = min(1, max(0, (angle - 40) / 70))
227
+ let orbit = -.pi / 2 * (1 - t * t * (3 - 2 * t))
228
+ let direction = SIMD3<Float>(Float(sin(orbit)), Float(cos(orbit)), 0)
229
+ let modelUp = SIMD3<Float>(0, 0, -1)
230
+ let right = simd_cross(modelUp, direction)
231
+ let rotation = Float(quarterTurns) * .pi / 2
232
+ let up = modelUp * cos(rotation) - right * sin(rotation)
233
+ let halfFOV = Float(31.0 * .pi / 360)
234
+ let vertical = tan(halfFOV) * 0.9
235
+ let horizontal = vertical * Float(bounds.width / bounds.height)
236
+ let posed = clip.bounds()
237
+ let cameraRight = simd_cross(up, direction)
238
+ let distance = posed.points.reduce(Float(0)) { distance, point in
239
+ let delta = point - posed.center
240
+ let depth = simd_dot(delta, direction)
241
+ return max(distance, depth + max(abs(simd_dot(delta, cameraRight)) / horizontal, abs(simd_dot(delta, up)) / vertical))
242
+ }
243
+ cameraNode.simdPosition = posed.center + direction * distance
244
+ cameraNode.look(
245
+ at: SCNVector3(posed.center.x, posed.center.y, posed.center.z),
246
+ up: SCNVector3(up.x, up.y, up.z), localFront: SCNVector3(0, 0, -1))
247
+ }
248
+
249
+ private func rotate(_ p: CGPoint, turns: Int) -> CGPoint {
250
+ switch (turns % 4 + 4) % 4 {
251
+ case 1: return CGPoint(x: p.y, y: 1 - p.x)
252
+ case 2: return CGPoint(x: 1 - p.x, y: 1 - p.y)
253
+ case 3: return CGPoint(x: 1 - p.y, y: p.x)
254
+ default: return p
255
+ }
256
+ }
257
+
258
+ private func textureTransform(turns: Int) -> SCNMatrix4 {
259
+ switch (turns % 4 + 4) % 4 {
260
+ case 1:
261
+ return SCNMatrix4(
262
+ m11: 0, m12: -1, m13: 0, m14: 0, m21: 1, m22: 0, m23: 0, m24: 0,
263
+ m31: 0, m32: 0, m33: 1, m34: 0, m41: 0, m42: 1, m43: 0, m44: 1)
264
+ case 2:
265
+ return SCNMatrix4(
266
+ m11: -1, m12: 0, m13: 0, m14: 0, m21: 0, m22: -1, m23: 0, m24: 0,
267
+ m31: 0, m32: 0, m33: 1, m34: 0, m41: 1, m42: 1, m43: 0, m44: 1)
268
+ case 3:
269
+ return SCNMatrix4(
270
+ m11: 0, m12: 1, m13: 0, m14: 0, m21: -1, m22: 0, m23: 0, m24: 0,
271
+ m31: 0, m32: 0, m33: 1, m34: 0, m41: 1, m42: 0, m43: 0, m44: 1)
272
+ default: return SCNMatrix4Identity
273
+ }
274
+ }
275
+ }
@@ -0,0 +1,140 @@
1
+ // Copyright (c) 2026 Siniulator contributors. Adapted under the MIT license in Support/Siniulator-LICENSE.txt.
2
+ import SceneKit
3
+ import simd
4
+
5
+ @MainActor final class DuoPoseClip {
6
+ static let openTime = 260.0 / 24
7
+ static let closedTime = 380.0 / 24
8
+ private enum Property { case position, orientation, scale }
9
+ private struct Track {
10
+ let node: SCNNode
11
+ let property: Property
12
+ let times: [Double]
13
+ let values: [SIMD4<Float>]
14
+ }
15
+ private let tracks: [Track]
16
+ private let supports: [(node: SCNNode, points: [SIMD4<Float>])]
17
+
18
+ init?(root: SCNNode) {
19
+ var nodes = [root]
20
+ root.enumerateChildNodes { node, _ in nodes.append(node) }
21
+ let geometryNodes = nodes.filter { $0.geometry != nil }
22
+ let meshes = geometryNodes.compactMap(DuoMesh.init)
23
+ guard !meshes.isEmpty, meshes.count == geometryNodes.count else { return nil }
24
+ var tracks: [Track] = []
25
+ for owner in nodes {
26
+ for key in owner.animationKeys {
27
+ guard let player = owner.animationPlayer(forKey: key),
28
+ let group = CAAnimation(scnAnimation: player.animation) as? CAAnimationGroup
29
+ else { continue }
30
+ for case let animation as CAKeyframeAnimation in group.animations ?? [] {
31
+ guard let path = animation.keyPath,
32
+ let separator = path.lastIndex(of: "."),
33
+ let values = animation.values as? [NSValue], !values.isEmpty
34
+ else { continue }
35
+ let name = String(path[..<separator]).split(separator: "/").last.map(String.init)
36
+ guard let node = nodes.first(where: { $0.name == name }) else { continue }
37
+ let property: Property
38
+ switch path[path.index(after: separator)...] {
39
+ case "position": property = .position
40
+ case "orientation": property = .orientation
41
+ case "scale": property = .scale
42
+ default: continue
43
+ }
44
+ let keyTimes: [Double]
45
+ if let authoredTimes = animation.keyTimes {
46
+ keyTimes = authoredTimes.map { $0.doubleValue }
47
+ } else {
48
+ let intervals = Double(max(1, values.count - 1))
49
+ keyTimes = values.indices.map { Double($0) / intervals }
50
+ }
51
+ let times: [Double] = keyTimes.map { animation.beginTime + $0 * animation.duration }
52
+ guard times.count == values.count else { continue }
53
+ tracks.append(
54
+ Track(
55
+ node: node, property: property, times: times,
56
+ values: values.map { value in
57
+ if property == .orientation {
58
+ let q = value.scnVector4Value
59
+ return SIMD4(Float(q.x), Float(q.y), Float(q.z), Float(q.w))
60
+ }
61
+ let v = value.scnVector3Value
62
+ return SIMD4(Float(v.x), Float(v.y), Float(v.z), 0)
63
+ }))
64
+ }
65
+ }
66
+ owner.removeAllAnimations()
67
+ }
68
+ guard !tracks.isEmpty else { return nil }
69
+ self.tracks = tracks
70
+
71
+ supports = meshes.flatMap { mesh -> [(node: SCNNode, points: [SIMD4<Float>])] in
72
+ let box = mesh.node.boundingBox
73
+ let minimum = SIMD3<Float>(Float(box.min.x), Float(box.min.y), Float(box.min.z))
74
+ let maximum = SIMD3<Float>(Float(box.max.x), Float(box.max.y), Float(box.max.z))
75
+ guard !mesh.bones.isEmpty else { return [(mesh.node, Self.corners(minimum, maximum))] }
76
+ var boxes = Array(
77
+ repeating: (SIMD3<Float>(repeating: .infinity), SIMD3<Float>(repeating: -.infinity)), count: mesh.bones.count)
78
+ for vertex in mesh.vertices.indices {
79
+ let v = mesh.vertices[vertex]
80
+ let point = SIMD3(v.x, v.y, v.z)
81
+ for influence in mesh.influences[vertex] {
82
+ boxes[influence.index].0 = simd_min(boxes[influence.index].0, point)
83
+ boxes[influence.index].1 = simd_max(boxes[influence.index].1, point)
84
+ }
85
+ }
86
+ return mesh.bones.enumerated().map { index, bone in
87
+ let box = boxes[index]
88
+ return (bone, box.0.x.isFinite ? Self.corners(box.0, box.1).map { mesh.binds[index] * $0 } : [])
89
+ }
90
+ }
91
+ Self.apply(tracks, time: Self.openTime)
92
+ }
93
+
94
+ func bounds() -> (center: SIMD3<Float>, points: [SIMD3<Float>]) {
95
+ let points = supports.flatMap { support in
96
+ support.points.map { point in
97
+ let transformed = support.node.simdWorldTransform * point
98
+ return SIMD3(transformed.x, transformed.y, transformed.z)
99
+ }
100
+ }
101
+ let minimum = points.reduce(SIMD3<Float>(repeating: .infinity)) { simd_min($0, $1) }
102
+ let maximum = points.reduce(SIMD3<Float>(repeating: -.infinity)) { simd_max($0, $1) }
103
+ return ((minimum + maximum) / 2, points)
104
+ }
105
+
106
+ func apply(angle: CGFloat) {
107
+ let t = Double(min(180, max(0, angle))) / 180
108
+ Self.apply(tracks, time: Self.closedTime + (Self.openTime - Self.closedTime) * t)
109
+ }
110
+
111
+ private static func corners(_ minimum: SIMD3<Float>, _ maximum: SIMD3<Float>) -> [SIMD4<Float>] {
112
+ [minimum.x, maximum.x].flatMap { x in
113
+ [minimum.y, maximum.y].flatMap { y in
114
+ [minimum.z, maximum.z].map { z in SIMD4(x, y, z, 1) }
115
+ }
116
+ }
117
+ }
118
+
119
+ private static func apply(_ tracks: [Track], time: Double) {
120
+ for track in tracks {
121
+ var lower = 0
122
+ var upper = track.times.count - 1
123
+ while lower + 1 < upper {
124
+ let middle = (lower + upper) / 2
125
+ if track.times[middle] <= time { lower = middle } else { upper = middle }
126
+ }
127
+ let span = track.times[upper] - track.times[lower]
128
+ let fraction = span > 0 ? Float(min(1, max(0, (time - track.times[lower]) / span))) : 0
129
+ let a = track.values[lower]
130
+ let b = track.values[upper]
131
+ if track.property == .orientation {
132
+ track.node.simdOrientation = simd_slerp(simd_quatf(vector: a), simd_quatf(vector: b), fraction)
133
+ } else {
134
+ let value = simd_mix(a, b, SIMD4(repeating: fraction))
135
+ let vector = SIMD3(value.x, value.y, value.z)
136
+ if track.property == .position { track.node.simdPosition = vector } else { track.node.simdScale = vector }
137
+ }
138
+ }
139
+ }
140
+ }