@stim-cli/server 1.13.0 → 1.15.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/README.md +1590 -42
- package/dist/protocol-MBMvJpvV.mjs +2715 -0
- package/dist/protocol.d.mts +2 -473
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5121 -514
- package/dist/scrcpy/LICENSE +204 -0
- package/dist/scrcpy/NOTICE +22 -0
- package/dist/scrcpy/scrcpy-server +0 -0
- package/dist/stats-read.d.mts +1 -0
- package/dist/stats-read.mjs +46 -0
- package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
- package/dist/stim-frames/DevToolsClient.swift +180 -0
- package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
- package/dist/stim-frames/DeviceFrameView.swift +167 -0
- package/dist/stim-frames/DuoCaptureSource.swift +132 -0
- package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
- package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
- package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
- package/dist/stim-frames/DuoMesh.swift +100 -0
- package/dist/stim-frames/DuoModelView.swift +275 -0
- package/dist/stim-frames/DuoPoseClip.swift +140 -0
- package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
- package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
- package/dist/stim-frames/EmulatorInput.swift +13 -2
- package/dist/stim-frames/EmulatorPosture.swift +2 -2
- package/dist/stim-frames/FrameArtwork.swift +37 -0
- package/dist/stim-frames/H264Decoder.swift +109 -0
- package/dist/stim-frames/InputMessages.swift +27 -6
- package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
- package/dist/stim-frames/MacosSource.swift +677 -0
- package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
- package/dist/stim-frames/OwnedAppWindows.swift +108 -0
- package/dist/stim-frames/PhoneSource.swift +315 -0
- package/dist/stim-frames/ProcessStream.swift +155 -0
- package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
- package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
- package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
- package/dist/stim-frames/SimulatorHID.swift +70 -29
- package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
- package/dist/stim-frames/SimulatorPosture.swift +277 -0
- package/dist/stim-frames/SimulatorRotation.swift +28 -8
- package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
- package/dist/stim-frames/StoragePaths.swift +42 -0
- package/dist/stim-frames/VideoEncoder.swift +8 -6
- package/dist/stim-frames/WebPage.swift +301 -0
- package/dist/stim-frames/main.swift +651 -22
- package/dist/stim-host/Info.plist +28 -0
- package/dist/stim-host/stim-host.c +153 -0
- package/dist/stim-server-OiJI9xLU.mjs +13336 -0
- package/dist/stim-server.mjs +6 -3880
- package/package.json +3 -3
- package/dist/protocol-rC-5KT1O.mjs +0 -822
|
@@ -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
|
+
}
|