@glassly/bluetooth-sdk 0.1.0-dev.99 → 0.2.8-dev.444
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 +47 -6
- package/android/build.gradle +7 -7
- package/android/lc3Lib/build.gradle +2 -2
- package/android/src/main/assets/G2Firmware/README.md +27 -2
- package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
- package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
- package/build/BluetoothSdk.types.d.ts +209 -20
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -1
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +30 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +28 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +37 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +102 -13
- package/ios/GlasslyBluetoothSDK.podspec +1 -1
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
- package/ios/Source/BluetoothSDK.swift +53 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +13 -0
- package/ios/Source/DeviceManager.swift +151 -33
- package/ios/Source/DeviceStore.swift +28 -14
- package/ios/Source/DisplayPacer.swift +180 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
- package/ios/Source/GlassesNotifications.swift +181 -0
- package/ios/Source/ObservableStore.swift +14 -2
- package/ios/Source/Resources/G2Firmware/README.md +27 -2
- package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/controllers/R1.swift +0 -1
- package/ios/Source/internal/BluetoothAvailability.swift +31 -0
- package/ios/Source/sgcs/G2.swift +1420 -495
- package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
- package/ios/Source/sgcs/G2BleLink.swift +102 -0
- package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
- package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
- package/ios/Source/sgcs/G2CfwScene.swift +779 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
- package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
- package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
- package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
- package/ios/Source/sgcs/G2Flasher.swift +9 -9
- package/ios/Source/sgcs/G2Keyboard.swift +780 -0
- package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
- package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
- package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
- package/ios/Source/sgcs/G2RingBattery.swift +22 -0
- package/ios/Source/sgcs/G2Sensors.swift +75 -0
- package/ios/Source/sgcs/G2TouchInput.swift +51 -0
- package/ios/Source/sgcs/G2WritePacing.swift +18 -0
- package/ios/Source/sgcs/MentraNex.swift +44 -44
- package/ios/Source/sgcs/SGCManager.swift +120 -13
- package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
- package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
- package/ios/Source/status/DeviceStatus.swift +27 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
- package/ios/Source/utils/Enums.swift +0 -1
- package/ios/Source/utils/G1Text.swift +0 -1
- package/ios/Source/utils/G2NotifyFile.swift +20 -10
- package/ios/Source/utils/JSCExperiment.swift +6 -6
- package/ios/Source/utils/Models.swift +0 -1
- package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
- package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
- package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
- package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
- package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
- package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
- package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
- package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
- package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
- package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
- package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
- package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +2 -2
- package/plugin/build/withIos.js +1 -1
- package/scripts/gen-nex-proto.sh +12 -0
- package/scripts/inject-ios-sdk-version.mjs +1 -1
- package/src/BluetoothSdk.types.ts +214 -19
- package/src/_internal.ts +1 -1
- package/src/_private/BluetoothSdkModule.ts +40 -0
- package/src/generated/changelogCatalog.ts +37 -1
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/index.ts +24 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Retained object cache encoder for the Glassly CFW (GLASSLYCFW/38, modes 37-41).
|
|
4
|
+
///
|
|
5
|
+
/// Every scene element becomes one or more OBJECTS with stable 32-bit ids
|
|
6
|
+
/// (derived from the element id and the object's position within the element):
|
|
7
|
+
/// vector shapes map 1:1; default and builtin text becomes one inline TEXT
|
|
8
|
+
/// object per line drawn by the glasses' font; mono text becomes TEXT_CACHED
|
|
9
|
+
/// runs drawn with a font asset (uploaded whole, once per cache generation);
|
|
10
|
+
/// images are rasterized on the phone and drawn as IMAGE objects tiled to the
|
|
11
|
+
/// 255 px limit, keyed by their pixels so identical images share one asset.
|
|
12
|
+
///
|
|
13
|
+
/// A frame is one SHOW: the ordered (id, version) references of what is on
|
|
14
|
+
/// screen, the embedded PUTs for objects the glasses do not hold yet (new, or
|
|
15
|
+
/// changed content: a version is a hash of the record), and TWEEN/ROTATE ops for
|
|
16
|
+
/// animated changes of geometry the glasses already hold. Objects that leave a
|
|
17
|
+
/// frame stay cached on the glasses (and in this mirror) until the firmware
|
|
18
|
+
/// evicts them least-recently-used, so a view coming back after a HIDE is pure
|
|
19
|
+
/// references. The firmware validates every reference; a MISSING reply names
|
|
20
|
+
/// what to redefine (`forget(objectIds:)`).
|
|
21
|
+
///
|
|
22
|
+
/// One instance mirrors one lens. Element `depth` is a stereo disparity: a
|
|
23
|
+
/// lens mirror encoding with `parallax` ±1 shifts each element by
|
|
24
|
+
/// `depth × parallax` px, so the renderer keeps a left and a right mirror once
|
|
25
|
+
/// a frame uses depth and sends each lens its own SHOW (`G2CfwRenderer`).
|
|
26
|
+
final class G2CfwScene {
|
|
27
|
+
static let maxObjects = 256
|
|
28
|
+
static let framePeriodMs = 33.0
|
|
29
|
+
static let textLineHeight = 27
|
|
30
|
+
static let textInset = 2
|
|
31
|
+
static let maxTextBytes = 64
|
|
32
|
+
static let maxInlineBytes = 128
|
|
33
|
+
static let maxTileSize = 255
|
|
34
|
+
/// Mode-15/20 options: identity ramp, transparent source color 0.
|
|
35
|
+
static let textOptions: UInt8 = 0x1F
|
|
36
|
+
static let imageOptions: UInt8 = 0x0F
|
|
37
|
+
static let allObjects: UInt32 = 0xFFFF_FFFF
|
|
38
|
+
|
|
39
|
+
enum ShapeType: UInt8 {
|
|
40
|
+
case line = 1, rect, rectFill, circle, circleFill, tri, triFill, quad, quadFill
|
|
41
|
+
case bezier2, bezier3, arc, pie, image, text, textCached, textInline, path
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// The id of the warm-up dot and its SHOW: a hidden two-frame tween on a
|
|
45
|
+
/// black 1 px dot, sent once per cache generation right after the RESET so
|
|
46
|
+
/// the first visible transition of a session is not also the first
|
|
47
|
+
/// animation the firmware ever runs (it creates its timer lazily).
|
|
48
|
+
static let warmUpId = G2CfwTextureCache.hash31("warm-up")
|
|
49
|
+
static let warmUpShow: Data = {
|
|
50
|
+
let dot = Slot(.circleFill, color: 0, [0, 0, 1])
|
|
51
|
+
return showPayload(
|
|
52
|
+
puts: [putObject(id: warmUpId, version: 1, record: objectRecord(dot))],
|
|
53
|
+
refs: [(warmUpId, 1)],
|
|
54
|
+
ops: [tweenOp(id: warmUpId, mask: 0x01, frames: 2, curve: [0, 0, 255, 255], values: [1])],
|
|
55
|
+
request: 0
|
|
56
|
+
)
|
|
57
|
+
}()
|
|
58
|
+
/// Mode 39: freeze, blank and keep everything cached.
|
|
59
|
+
static let hidePayload = Data([39, 0, 0, 0, 0, 0x01, 0])
|
|
60
|
+
/// Mode 41 sub 0: empty both caches and adopt the epoch the link stamps in.
|
|
61
|
+
static let resetPayload = Data([41, 0, 0, 0, 0, 0])
|
|
62
|
+
|
|
63
|
+
struct Slot: Equatable {
|
|
64
|
+
var type: UInt8
|
|
65
|
+
var color: UInt8
|
|
66
|
+
var width: UInt8
|
|
67
|
+
var params: [Int16]
|
|
68
|
+
/// TEXT_INLINE bytes carried in the record.
|
|
69
|
+
var text: [UInt8]
|
|
70
|
+
var path: [UInt8]
|
|
71
|
+
var fillRule: UInt8
|
|
72
|
+
/// Asset ids for IMAGE (image), TEXT (string), TEXT_CACHED (string + font).
|
|
73
|
+
var asset: UInt32 = 0
|
|
74
|
+
var font: UInt32 = 0
|
|
75
|
+
var angle: Int32 = 0
|
|
76
|
+
var pivotX: Int16 = 0
|
|
77
|
+
var pivotY: Int16 = 0
|
|
78
|
+
|
|
79
|
+
func sameGeometry(as other: Slot) -> Bool {
|
|
80
|
+
type == other.type && color == other.color && width == other.width && params == other.params
|
|
81
|
+
&& text == other.text && path == other.path && fillRule == other.fillRule
|
|
82
|
+
&& asset == other.asset && font == other.font
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
var rotatable: Bool {
|
|
86
|
+
type <= 13 || type == 18
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
init(_ type: ShapeType, color: UInt8, width: UInt8 = 0, _ params: [Int16], text: [UInt8] = [], path: [UInt8] = [],
|
|
90
|
+
fillRule: UInt8 = 0, asset: UInt32 = 0, font: UInt32 = 0) {
|
|
91
|
+
self.type = type.rawValue
|
|
92
|
+
self.color = color
|
|
93
|
+
self.width = width
|
|
94
|
+
var padded = params
|
|
95
|
+
while padded.count < 8 {
|
|
96
|
+
padded.append(0)
|
|
97
|
+
}
|
|
98
|
+
self.params = Array(padded.prefix(8))
|
|
99
|
+
self.text = text
|
|
100
|
+
self.path = path
|
|
101
|
+
self.fillRule = fillRule
|
|
102
|
+
self.asset = asset
|
|
103
|
+
self.font = font
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// What this mirror knows the glasses hold for one object.
|
|
108
|
+
struct Known {
|
|
109
|
+
/// Version of the definition resident on the glasses and what it defines.
|
|
110
|
+
var version: UInt32
|
|
111
|
+
var definition: Slot
|
|
112
|
+
/// The geometry the glasses currently show (ops move it without a new
|
|
113
|
+
/// version); nil after a hide mid-flight, when it must be re-snapped.
|
|
114
|
+
var state: Slot?
|
|
115
|
+
var animEndsAt: TimeInterval
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
struct Encoded {
|
|
119
|
+
/// The SHOW.
|
|
120
|
+
let payload: Data
|
|
121
|
+
/// Standalone PUT messages (asset uploads) that must precede it.
|
|
122
|
+
let uploads: [Data]
|
|
123
|
+
/// False when the frame is the one already on screen (nothing to send).
|
|
124
|
+
let changed: Bool
|
|
125
|
+
/// Objects the frame redefined / referenced from the cache / tweened.
|
|
126
|
+
let puts: Int
|
|
127
|
+
let hits: Int
|
|
128
|
+
let tweens: Int
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Changing bitmaps benefit from compressed dirty rectangles, without
|
|
132
|
+
/// allocating new retained textures every frame. Transitions still need objects.
|
|
133
|
+
static func prefersRaster(_ frame: SceneFrame) -> Bool {
|
|
134
|
+
!frame.elements.isEmpty && frame.elements.allSatisfy { $0.type == "image" && $0.transitionMs == 0 && $0.depth == 0 }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// The phone's monospaced face for TEXT_CACHED runs: fixed cell metrics per
|
|
138
|
+
/// font size, and the full-cell glyph raster (cellWidth × lineHeight, 8-bit
|
|
139
|
+
/// gray, white ink) for one char — the same raster the frame blits use.
|
|
140
|
+
struct MonoFace {
|
|
141
|
+
let metrics: (Int32) -> (cellWidth: Int, lineHeight: Int)
|
|
142
|
+
let glyph: (Unicode.Scalar, Int32, Int, Int) -> Data?
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private(set) var generation = -1
|
|
146
|
+
private(set) var known: [UInt32: Known] = [:]
|
|
147
|
+
/// Object ids of the frame on screen, in paint order; empty after a hide.
|
|
148
|
+
private(set) var active: [UInt32] = []
|
|
149
|
+
/// Element id → its object ids, for the last encoded frame.
|
|
150
|
+
private(set) var objectIds: [[UInt32]] = []
|
|
151
|
+
|
|
152
|
+
/// Forget everything: the on-glass cache is empty or unknown.
|
|
153
|
+
func reset(generation: Int) {
|
|
154
|
+
self.generation = generation
|
|
155
|
+
known.removeAll()
|
|
156
|
+
active.removeAll()
|
|
157
|
+
objectIds.removeAll()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/// The residency of every object is unknown (a send failed): the next frame redefines everything it shows.
|
|
161
|
+
func invalidate() {
|
|
162
|
+
known.removeAll()
|
|
163
|
+
active.removeAll()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// The panel went blank (HIDE or a raster takeover): objects animating right
|
|
167
|
+
/// now froze mid-flight on the glasses, so they are re-snapped when shown again.
|
|
168
|
+
func hidden() {
|
|
169
|
+
let now = Date().timeIntervalSince1970
|
|
170
|
+
for id in active {
|
|
171
|
+
if let k = known[id], k.animEndsAt > now {
|
|
172
|
+
known[id]?.state = nil
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
active.removeAll()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// The glasses reported these object ids absent (or at another version).
|
|
179
|
+
func forget(objectIds ids: [UInt32]) {
|
|
180
|
+
for id in ids {
|
|
181
|
+
known.removeValue(forKey: id)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Become a copy of `other`: the second lens mirror starts from the shared
|
|
186
|
+
/// state the moment the lenses diverge (the first frame with depth).
|
|
187
|
+
func adopt(_ other: G2CfwScene) {
|
|
188
|
+
generation = other.generation
|
|
189
|
+
known = other.known
|
|
190
|
+
active = other.active
|
|
191
|
+
objectIds = other.objectIds
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// MARK: - Element → slots
|
|
195
|
+
|
|
196
|
+
/// Geometry parameters a TWEEN may interpolate, per type. Asset ids never tween.
|
|
197
|
+
static func tweenableMask(_ type: UInt8) -> UInt16 {
|
|
198
|
+
switch ShapeType(rawValue: type) {
|
|
199
|
+
case .path: return 0x07
|
|
200
|
+
case .line: return 0x0F
|
|
201
|
+
case .rect, .rectFill, .arc, .pie: return 0x1F
|
|
202
|
+
case .circle, .circleFill: return 0x07
|
|
203
|
+
case .tri, .triFill, .bezier2: return 0x3F
|
|
204
|
+
case .quad, .quadFill, .bezier3: return 0xFF
|
|
205
|
+
case .image, .text, .textCached: return 0x03
|
|
206
|
+
case .textInline: return 0x0F
|
|
207
|
+
case .none: return 0
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
static func easingCurve(_ easing: String) -> [UInt8] {
|
|
212
|
+
switch easing {
|
|
213
|
+
case "linear": return [0, 0, 255, 255]
|
|
214
|
+
case "ease-in": return [107, 0, 255, 255]
|
|
215
|
+
case "ease-out": return [0, 0, 148, 255]
|
|
216
|
+
case "ease-in-out": return [107, 0, 148, 255]
|
|
217
|
+
default: return [64, 26, 64, 255] // CSS "ease"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/// FNV-1a 64 of a tile's pixels: the asset key, so elements with the same
|
|
222
|
+
/// pixels (one icon on several cards, at any ink) share one upload.
|
|
223
|
+
static func pixelHash(_ pixels: Data) -> String {
|
|
224
|
+
var hash: UInt64 = 0xCBF2_9CE4_8422_2325
|
|
225
|
+
for byte in pixels {
|
|
226
|
+
hash ^= UInt64(byte)
|
|
227
|
+
hash = hash &* 0x0000_0100_0000_01B3
|
|
228
|
+
}
|
|
229
|
+
return String(hash, radix: 36)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
static func transitionFrames(ms: Int32) -> UInt8 {
|
|
233
|
+
guard ms > 0 else { return 0 }
|
|
234
|
+
let frames = Int((Double(ms) / framePeriodMs).rounded())
|
|
235
|
+
return UInt8(max(2, min(255, frames)))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Object id of the k-th slot of an element: stable across frames and reopenings.
|
|
239
|
+
static func objectId(_ elementId: String, _ k: Int) -> UInt32 {
|
|
240
|
+
G2CfwTextureCache.hash31("\(elementId)#\(k)")
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// Content version of a slot: a hash of the record the glasses store.
|
|
244
|
+
static func version(of slot: Slot) -> UInt32 {
|
|
245
|
+
G2CfwTextureCache.hash31(objectRecord(slot))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private static func gray(_ element: SceneElement) -> UInt8 {
|
|
249
|
+
element.color < 0 ? 15 : UInt8(max(0, min(15, element.color)))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private static func stroke(_ element: SceneElement) -> UInt8 {
|
|
253
|
+
UInt8(max(1, min(255, element.border)))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private static func point(_ element: SceneElement, _ i: Int, ox: Int, oy: Int) -> (Int16, Int16) {
|
|
257
|
+
guard element.points.count >= 2 * i + 2 else { return (0, 0) }
|
|
258
|
+
return (clampCoord(Int(element.points[2 * i]) + ox), clampCoord(Int(element.points[2 * i + 1]) + oy))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private static func clampCoord(_ v: Int) -> Int16 {
|
|
262
|
+
Int16(max(-1024, min(1023, v)))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private static func circleGeometry(_ element: SceneElement, ox: Int, oy: Int) -> (Int16, Int16, Int16) {
|
|
266
|
+
let w = Int(element.w), h = Int(element.h)
|
|
267
|
+
let r = max(0, (min(w, h) - 1) / 2)
|
|
268
|
+
return (clampCoord(Int(element.x) + w / 2 + ox), clampCoord(Int(element.y) + h / 2 + oy), Int16(r))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/// Slots for one element, or nil when the asset store cannot hold its content.
|
|
272
|
+
/// `inlineText` selects TEXT_INLINE records for default/builtin text; without
|
|
273
|
+
/// `mono`, mono text falls back to image tiles.
|
|
274
|
+
func slots(
|
|
275
|
+
for element: SceneElement, originX ox: Int, originY oy: Int, inlineText: Bool,
|
|
276
|
+
mono: MonoFace? = nil, cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
|
|
277
|
+
) -> [Slot]? {
|
|
278
|
+
let x = Self.clampCoord(Int(element.x) + ox)
|
|
279
|
+
let y = Self.clampCoord(Int(element.y) + oy)
|
|
280
|
+
let w = Int16(max(0, min(2047, Int(element.w))))
|
|
281
|
+
let h = Int16(max(0, min(2047, Int(element.h))))
|
|
282
|
+
let color = Self.gray(element)
|
|
283
|
+
let radius = Int16(max(0, min(2047, Int(element.radius))))
|
|
284
|
+
switch element.type {
|
|
285
|
+
case "svg":
|
|
286
|
+
guard !element.path.isEmpty, element.path.count <= 8192, (0 ... 2048).contains(element.pathScale) else { return nil }
|
|
287
|
+
return [Slot(.path, color: color, [x, y, Int16(element.pathScale)], path: element.path, fillRule: UInt8(clamping: element.fillRule))]
|
|
288
|
+
case "rect":
|
|
289
|
+
var out: [Slot] = []
|
|
290
|
+
if element.fill {
|
|
291
|
+
out.append(Slot(.rectFill, color: color, [x, y, w, h, radius]))
|
|
292
|
+
if element.border > 0 {
|
|
293
|
+
out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
|
|
297
|
+
}
|
|
298
|
+
return out
|
|
299
|
+
case "circle":
|
|
300
|
+
let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
|
|
301
|
+
if element.fill {
|
|
302
|
+
return [Slot(.circleFill, color: color, [cx, cy, r])]
|
|
303
|
+
}
|
|
304
|
+
return [Slot(.circle, color: color, width: Self.stroke(element), [cx, cy, r])]
|
|
305
|
+
case "arc", "pie":
|
|
306
|
+
let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
|
|
307
|
+
let a0 = Self.clampCoord(Int(element.startDeg)), a1 = Self.clampCoord(Int(element.endDeg))
|
|
308
|
+
if element.type == "pie" {
|
|
309
|
+
return [Slot(.pie, color: color, [cx, cy, r, a0, a1])]
|
|
310
|
+
}
|
|
311
|
+
return [Slot(.arc, color: color, width: Self.stroke(element), [cx, cy, r, a0, a1])]
|
|
312
|
+
case "line":
|
|
313
|
+
let (x1, y1) = Self.point(element, 0, ox: ox, oy: oy)
|
|
314
|
+
let (x2, y2) = Self.point(element, 1, ox: ox, oy: oy)
|
|
315
|
+
return [Slot(.line, color: color, width: Self.stroke(element), [x1, y1, x2, y2])]
|
|
316
|
+
case "triangle", "quad":
|
|
317
|
+
let n = element.type == "triangle" ? 3 : 4
|
|
318
|
+
var params: [Int16] = []
|
|
319
|
+
for i in 0 ..< n {
|
|
320
|
+
let (px, py) = Self.point(element, i, ox: ox, oy: oy)
|
|
321
|
+
params.append(contentsOf: [px, py])
|
|
322
|
+
}
|
|
323
|
+
let filled: ShapeType = n == 3 ? .triFill : .quadFill
|
|
324
|
+
let stroked: ShapeType = n == 3 ? .tri : .quad
|
|
325
|
+
if element.fill {
|
|
326
|
+
return [Slot(filled, color: color, params)]
|
|
327
|
+
}
|
|
328
|
+
return [Slot(stroked, color: color, width: Self.stroke(element), params)]
|
|
329
|
+
case "bezier":
|
|
330
|
+
let n = element.points.count / 2
|
|
331
|
+
guard n == 3 || n == 4 else { return [] }
|
|
332
|
+
var params: [Int16] = []
|
|
333
|
+
for i in 0 ..< n {
|
|
334
|
+
let (px, py) = Self.point(element, i, ox: ox, oy: oy)
|
|
335
|
+
params.append(contentsOf: [px, py])
|
|
336
|
+
}
|
|
337
|
+
return [Slot(n == 3 ? .bezier2 : .bezier3, color: color, width: Self.stroke(element), params)]
|
|
338
|
+
case "text" where element.font == "mono":
|
|
339
|
+
guard let mono = mono else { return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize) }
|
|
340
|
+
return monoTextSlots(element, x: x, y: y, cache: cache, mono: mono, rasterize: rasterize)
|
|
341
|
+
case "text" where inlineText:
|
|
342
|
+
return inlineTextSlots(element, x: x, y: y)
|
|
343
|
+
case "text" where element.font == "builtin":
|
|
344
|
+
return builtinTextSlots(element, x: x, y: y, cache: cache)
|
|
345
|
+
case "text", "image":
|
|
346
|
+
return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
|
|
347
|
+
default:
|
|
348
|
+
return []
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private func builtinTextSlots(_ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache) -> [Slot]? {
|
|
353
|
+
var out: [Slot] = []
|
|
354
|
+
if element.border > 0 {
|
|
355
|
+
out.append(Slot(.rect, color: 15, width: Self.stroke(element),
|
|
356
|
+
[x, y, Int16(min(2047, Int(element.w))), Int16(min(2047, Int(element.h))),
|
|
357
|
+
Int16(max(0, min(2047, Int(element.radius))))]))
|
|
358
|
+
}
|
|
359
|
+
let maxLines = max(1, Int(element.h) / Self.textLineHeight)
|
|
360
|
+
let lines = (element.text ?? "").split(separator: "\n", omittingEmptySubsequences: false).prefix(maxLines)
|
|
361
|
+
for (index, line) in lines.enumerated() {
|
|
362
|
+
let bytes = Self.textBytes(String(line))
|
|
363
|
+
if bytes.isEmpty {
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
guard let asset = cache.ensureBytes(key: "t:" + String(line), bytes: bytes) else { return nil }
|
|
367
|
+
let lineY = Int(y) + index * Self.textLineHeight
|
|
368
|
+
out.append(Slot(.text, color: Self.textOptions, [x, Self.clampCoord(lineY)], asset: asset))
|
|
369
|
+
}
|
|
370
|
+
return out
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// One TEXT_INLINE object per line, drawn by the glasses' font at the same 2 px
|
|
374
|
+
/// inset as phone-rasterized text, clipped to the box.
|
|
375
|
+
private func inlineTextSlots(_ element: SceneElement, x: Int16, y: Int16) -> [Slot] {
|
|
376
|
+
var out: [Slot] = []
|
|
377
|
+
let w = Int16(max(0, min(2047, Int(element.w))))
|
|
378
|
+
let h = Int16(max(0, min(2047, Int(element.h))))
|
|
379
|
+
if element.border > 0 {
|
|
380
|
+
out.append(Slot(.rect, color: Self.gray(element), width: Self.stroke(element),
|
|
381
|
+
[x, y, w, h, Int16(max(0, min(2047, Int(element.radius))))]))
|
|
382
|
+
}
|
|
383
|
+
let options = UInt8(0x10) | Self.gray(element)
|
|
384
|
+
let maxLines = max(1, Int(element.h) / Self.textLineHeight)
|
|
385
|
+
let lines = (element.text ?? "").split(separator: "\n", omittingEmptySubsequences: false).prefix(maxLines)
|
|
386
|
+
for (index, line) in lines.enumerated() {
|
|
387
|
+
let bytes = Self.textBytes(String(line), max: Self.maxInlineBytes)
|
|
388
|
+
if bytes.isEmpty {
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
let lineY = Int(y) + Self.textInset + index * Self.textLineHeight
|
|
392
|
+
let clipW = max(0, Int(element.w) - Self.textInset)
|
|
393
|
+
let clipH = max(0, Int(element.h) - Self.textInset - index * Self.textLineHeight)
|
|
394
|
+
out.append(Slot(.textInline, color: options,
|
|
395
|
+
[Self.clampCoord(Int(x) + Self.textInset), Self.clampCoord(lineY),
|
|
396
|
+
Int16(min(2047, clipW)), Int16(min(2047, clipH)), Int16(bytes.count)],
|
|
397
|
+
text: bytes))
|
|
398
|
+
}
|
|
399
|
+
return out
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary.
|
|
403
|
+
static func textBytes(_ line: String, max limit: Int = maxTextBytes) -> [UInt8] {
|
|
404
|
+
let cleaned = line.unicodeScalars.filter { $0.value >= 32 }
|
|
405
|
+
var bytes: [UInt8] = []
|
|
406
|
+
for scalar in cleaned {
|
|
407
|
+
let encoded = Array(String(scalar).utf8)
|
|
408
|
+
if bytes.count + encoded.count > limit {
|
|
409
|
+
break
|
|
410
|
+
}
|
|
411
|
+
bytes.append(contentsOf: encoded)
|
|
412
|
+
}
|
|
413
|
+
return bytes
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/// Wire codes of the half-block grid font: mode 20 indexes a font by
|
|
417
|
+
/// ASCII byte, so its glyphs need not be ASCII. Kept apart from the real ASCII font.
|
|
418
|
+
static let blockCodes: [UInt32: UInt8] = [32: 32, 0x2580: 65, 0x2584: 66, 0x2588: 67]
|
|
419
|
+
|
|
420
|
+
/// Mono text as TEXT_CACHED runs: each line is a string asset drawn with
|
|
421
|
+
/// the font asset for its size, so glyphs upload once per generation and a
|
|
422
|
+
/// changed string costs only its bytes. Cells sit on the raster path's
|
|
423
|
+
/// grid (2 px inset, partial edge cells kept); runs split at the 64-byte
|
|
424
|
+
/// limit. A separate font maps half-block grids to printable wire bytes;
|
|
425
|
+
/// other non-ASCII text keeps the image-tile path.
|
|
426
|
+
private func monoTextSlots(
|
|
427
|
+
_ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache, mono: MonoFace,
|
|
428
|
+
rasterize: (SceneElement, Int, Int) -> Data?
|
|
429
|
+
) -> [Slot]? {
|
|
430
|
+
let metrics = mono.metrics(element.fontSize)
|
|
431
|
+
let cellW = metrics.cellWidth, lineH = metrics.lineHeight
|
|
432
|
+
guard cellW > 0, lineH > 0 else { return [] }
|
|
433
|
+
let cols = max(0, (Int(element.w) - Self.textInset + cellW - 1) / cellW)
|
|
434
|
+
let rows = max(0, (Int(element.h) - Self.textInset + lineH - 1) / lineH)
|
|
435
|
+
let lines: [[Unicode.Scalar]] = (element.text ?? "")
|
|
436
|
+
.split(separator: "\n", omittingEmptySubsequences: false).prefix(rows)
|
|
437
|
+
.map { Array($0.unicodeScalars.prefix(cols)) }
|
|
438
|
+
let blockGrid = lines.contains { $0.contains { $0.value > 126 } }
|
|
439
|
+
&& lines.allSatisfy { $0.allSatisfy { Self.blockCodes[$0.value] != nil } }
|
|
440
|
+
guard blockGrid || lines.allSatisfy({ $0.allSatisfy { $0.value >= 32 && $0.value <= 126 } }) else {
|
|
441
|
+
return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
|
|
442
|
+
}
|
|
443
|
+
let variant = blockGrid ? "blocks" : "ascii"
|
|
444
|
+
let codes: [UInt8] = blockGrid ? [32, 65, 66, 67] : Array(32 ... 126)
|
|
445
|
+
let scalarFor: (UInt8) -> Unicode.Scalar? = { code in
|
|
446
|
+
if blockGrid {
|
|
447
|
+
return Self.blockCodes.first { $0.value == code }.flatMap { Unicode.Scalar($0.key) }
|
|
448
|
+
}
|
|
449
|
+
return Unicode.Scalar(UInt32(code))
|
|
450
|
+
}
|
|
451
|
+
guard let font = cache.font(fontSize: element.fontSize, cellWidth: cellW, lineHeight: lineH, variant: variant, codes: codes, rasterize: { code in
|
|
452
|
+
scalarFor(code).flatMap { mono.glyph($0, element.fontSize, cellW, lineH) }
|
|
453
|
+
}) else { return nil }
|
|
454
|
+
// Opaque cells (options bit 4 clear): the grid owns its cells, as the raster path's blits do.
|
|
455
|
+
let options = Self.gray(element)
|
|
456
|
+
var out: [Slot] = []
|
|
457
|
+
for (row, line) in lines.enumerated() where !line.isEmpty {
|
|
458
|
+
let lineY = Self.clampCoord(Int(y) + Self.textInset + row * lineH)
|
|
459
|
+
var start = 0
|
|
460
|
+
while start < line.count {
|
|
461
|
+
let run = Array(line[start ..< min(line.count, start + Self.maxTextBytes)])
|
|
462
|
+
let bytes = run.map { blockGrid ? Self.blockCodes[$0.value]! : UInt8($0.value) }
|
|
463
|
+
// A glyph the font could not raster keeps the tile path for the element.
|
|
464
|
+
guard bytes.allSatisfy({ $0 == 32 || font.glyphs.contains($0) }) else {
|
|
465
|
+
return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
|
|
466
|
+
}
|
|
467
|
+
guard let asset = cache.ensureBytes(key: "t:\(variant):" + String(run.map { Character($0) }), bytes: bytes)
|
|
468
|
+
else { return nil }
|
|
469
|
+
out.append(Slot(.textCached, color: options,
|
|
470
|
+
[Self.clampCoord(Int(x) + Self.textInset + start * cellW), lineY],
|
|
471
|
+
asset: asset, font: font.id))
|
|
472
|
+
start += run.count
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return out
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private func rasterSlots(
|
|
479
|
+
_ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache,
|
|
480
|
+
rasterize: (SceneElement, Int, Int) -> Data?
|
|
481
|
+
) -> [Slot]? {
|
|
482
|
+
let w = Int(element.w), h = Int(element.h)
|
|
483
|
+
guard w > 0, h > 0, let gray = rasterize(element, w, h), gray.count == w * h else { return [] }
|
|
484
|
+
// An image's ink is the ramp's top color; rasterized text keeps its ink in the pixels.
|
|
485
|
+
let options = element.type == "image" ? Self.gray(element) : Self.imageOptions
|
|
486
|
+
var out: [Slot] = []
|
|
487
|
+
var ty = 0
|
|
488
|
+
while ty < h {
|
|
489
|
+
let th = min(Self.maxTileSize, h - ty)
|
|
490
|
+
var tx = 0
|
|
491
|
+
while tx < w {
|
|
492
|
+
let tw = min(Self.maxTileSize, w - tx)
|
|
493
|
+
var tile = Data(capacity: tw * th)
|
|
494
|
+
for row in 0 ..< th {
|
|
495
|
+
let start = (ty + row) * w + tx
|
|
496
|
+
tile.append(gray.subdata(in: start ..< start + tw))
|
|
497
|
+
}
|
|
498
|
+
let key = "px:\(Self.pixelHash(tile))@\(tw)x\(th)"
|
|
499
|
+
guard let asset = cache.ensureImage(hash: key, gray: tile, width: tw, height: th) else { return nil }
|
|
500
|
+
out.append(Slot(.image, color: options,
|
|
501
|
+
[Self.clampCoord(Int(x) + tx), Self.clampCoord(Int(y) + ty)], asset: asset))
|
|
502
|
+
tx += tw
|
|
503
|
+
}
|
|
504
|
+
ty += th
|
|
505
|
+
}
|
|
506
|
+
return out
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// MARK: - Frame encoding
|
|
510
|
+
|
|
511
|
+
/// One SHOW (embedded object PUTs, references in paint order, ops) and the
|
|
512
|
+
/// asset PUT messages that must precede it. Nil when the frame does not fit
|
|
513
|
+
/// the object table or the asset store; the caller then falls back to the
|
|
514
|
+
/// raster path. `tag` (the host frame number) becomes the SHOW's request id
|
|
515
|
+
/// with the TAG flag, so settings field 129 reports it once the scene settles.
|
|
516
|
+
/// `parallax` is +1 for the left lens mirror and -1 for the right: each
|
|
517
|
+
/// element shifts by `depth × parallax` px; 0 draws every element flat.
|
|
518
|
+
func encode(
|
|
519
|
+
_ frame: SceneFrame, originX: Int, originY: Int, inlineText: Bool = false, mono: MonoFace? = nil,
|
|
520
|
+
tag: Int? = nil, parallax: Int = 0, cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
|
|
521
|
+
) -> Encoded? {
|
|
522
|
+
struct Desired {
|
|
523
|
+
let id: String
|
|
524
|
+
let slots: [Slot]
|
|
525
|
+
let frames: UInt8
|
|
526
|
+
let duration: Int32
|
|
527
|
+
let curve: [UInt8]
|
|
528
|
+
}
|
|
529
|
+
var desired: [Desired] = []
|
|
530
|
+
for element in frame.elements {
|
|
531
|
+
let ox = originX + Int(element.depth) * parallax
|
|
532
|
+
guard var slots = slots(for: element, originX: ox, originY: originY, inlineText: inlineText,
|
|
533
|
+
mono: mono, cache: cache, rasterize: rasterize)
|
|
534
|
+
else { return nil }
|
|
535
|
+
for i in slots.indices where slots[i].rotatable {
|
|
536
|
+
slots[i].angle = element.rotationQ8
|
|
537
|
+
slots[i].pivotX = Int16(clamping: Int(element.pivotX ?? (element.x + element.w / 2)) + ox)
|
|
538
|
+
slots[i].pivotY = Int16(clamping: Int(element.pivotY ?? (element.y + element.h / 2)) + originY)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
desired.append(Desired(
|
|
542
|
+
id: element.id, slots: slots,
|
|
543
|
+
frames: Self.transitionFrames(ms: element.transitionMs), duration: element.transitionMs,
|
|
544
|
+
curve: Self.easingCurve(element.easing)
|
|
545
|
+
))
|
|
546
|
+
}
|
|
547
|
+
guard desired.reduce(0, { $0 + $1.slots.count }) <= Self.maxObjects else { return nil }
|
|
548
|
+
|
|
549
|
+
let now = Date().timeIntervalSince1970
|
|
550
|
+
var puts: [Data] = []
|
|
551
|
+
var refs: [(UInt32, UInt32)] = []
|
|
552
|
+
var ops = Data()
|
|
553
|
+
var putCount = 0, hits = 0, tweens = 0
|
|
554
|
+
var ids: [[UInt32]] = []
|
|
555
|
+
var seen = Set<UInt32>()
|
|
556
|
+
// Only objects the previous frame showed may animate: one coming back to
|
|
557
|
+
// the frame appears where it is asked to be, not tweened from where it left.
|
|
558
|
+
let onScreen = Set(active)
|
|
559
|
+
for item in desired {
|
|
560
|
+
var mine: [UInt32] = []
|
|
561
|
+
for (k, slot) in item.slots.enumerated() {
|
|
562
|
+
let id = Self.objectId(item.id, k)
|
|
563
|
+
guard !seen.contains(id) else { continue }
|
|
564
|
+
seen.insert(id)
|
|
565
|
+
mine.append(id)
|
|
566
|
+
let version = Self.version(of: slot)
|
|
567
|
+
func put() {
|
|
568
|
+
puts.append(Self.putObject(id: id, version: version, record: Self.objectRecord(slot)))
|
|
569
|
+
if slot.angle != 0 {
|
|
570
|
+
ops.append(Self.rotationOp(slot, id: id, duration: 0, curve: item.curve))
|
|
571
|
+
}
|
|
572
|
+
putCount += 1
|
|
573
|
+
known[id] = Known(version: version, definition: slot, state: slot, animEndsAt: 0)
|
|
574
|
+
refs.append((id, version))
|
|
575
|
+
}
|
|
576
|
+
guard var k0 = known[id] else { put(); continue }
|
|
577
|
+
let animate = onScreen.contains(id)
|
|
578
|
+
if let current = k0.state {
|
|
579
|
+
if current == slot {
|
|
580
|
+
hits += 1
|
|
581
|
+
refs.append((id, k0.version))
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
584
|
+
let rotationChanged = current.angle != slot.angle
|
|
585
|
+
|| ((current.angle != 0 || slot.angle != 0) && (current.pivotX != slot.pivotX || current.pivotY != slot.pivotY))
|
|
586
|
+
if current.sameGeometry(as: slot) {
|
|
587
|
+
if rotationChanged {
|
|
588
|
+
let duration = animate ? item.duration : 0
|
|
589
|
+
ops.append(Self.rotationOp(slot, id: id, duration: duration, curve: item.curve))
|
|
590
|
+
k0.animEndsAt = now + Double(duration) / 1000
|
|
591
|
+
}
|
|
592
|
+
k0.state = slot
|
|
593
|
+
known[id] = k0
|
|
594
|
+
hits += 1
|
|
595
|
+
refs.append((id, k0.version))
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
if animate, item.frames >= 2, let tween = Self.tweenOp(from: current, to: slot, id: id, frames: item.frames, curve: item.curve) {
|
|
599
|
+
ops.append(tween)
|
|
600
|
+
if rotationChanged {
|
|
601
|
+
ops.append(Self.rotationOp(slot, id: id, duration: item.duration, curve: item.curve))
|
|
602
|
+
}
|
|
603
|
+
tweens += 1
|
|
604
|
+
k0.state = slot
|
|
605
|
+
k0.animEndsAt = now + Double(Int(item.frames)) * Self.framePeriodMs / 1000
|
|
606
|
+
known[id] = k0
|
|
607
|
+
refs.append((id, k0.version))
|
|
608
|
+
continue
|
|
609
|
+
}
|
|
610
|
+
// The glasses hold this exact definition and earlier ops only moved
|
|
611
|
+
// it: a same-version PUT is a no-op there, so snap the state instead.
|
|
612
|
+
if k0.version == version, let snap = Self.tweenOp(from: current, to: slot, id: id, frames: 0, curve: item.curve) {
|
|
613
|
+
ops.append(snap)
|
|
614
|
+
if rotationChanged {
|
|
615
|
+
ops.append(Self.rotationOp(slot, id: id, duration: 0, curve: item.curve))
|
|
616
|
+
}
|
|
617
|
+
k0.state = slot
|
|
618
|
+
k0.animEndsAt = 0
|
|
619
|
+
known[id] = k0
|
|
620
|
+
hits += 1
|
|
621
|
+
refs.append((id, k0.version))
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
put()
|
|
625
|
+
continue
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Frozen mid-flight by a hide: snap it back to the geometry it should show.
|
|
629
|
+
if let snap = Self.tweenOp(from: k0.definition, to: slot, id: id, frames: 0, curve: item.curve) {
|
|
630
|
+
ops.append(snap)
|
|
631
|
+
if slot.angle != 0 || k0.definition.angle != 0 {
|
|
632
|
+
ops.append(Self.rotationOp(slot, id: id, duration: 0, curve: item.curve))
|
|
633
|
+
}
|
|
634
|
+
k0.state = slot
|
|
635
|
+
known[id] = k0
|
|
636
|
+
hits += 1
|
|
637
|
+
refs.append((id, k0.version))
|
|
638
|
+
continue
|
|
639
|
+
}
|
|
640
|
+
put()
|
|
641
|
+
}
|
|
642
|
+
ids.append(mine)
|
|
643
|
+
}
|
|
644
|
+
let listChanged = refs.map { $0.0 } != active
|
|
645
|
+
active = refs.map { $0.0 }
|
|
646
|
+
objectIds = ids
|
|
647
|
+
let payload = Self.showPayload(puts: puts, refs: refs, ops: [ops], request: tag.map { UInt16(truncatingIfNeeded: $0) } ?? 0,
|
|
648
|
+
tag: (tag ?? 0) > 0)
|
|
649
|
+
return Encoded(payload: payload, uploads: cache.takeUploadPayloads(),
|
|
650
|
+
changed: listChanged || !puts.isEmpty || !ops.isEmpty, puts: putCount, hits: hits, tweens: tweens)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// MARK: - Wire records
|
|
654
|
+
|
|
655
|
+
private static func appendI16(_ value: Int16, to out: inout Data) {
|
|
656
|
+
let bits = UInt16(bitPattern: value)
|
|
657
|
+
out.append(UInt8(bits & 0xFF))
|
|
658
|
+
out.append(UInt8(bits >> 8))
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/// The shape record the object cache stores for a slot (shapes.h layouts).
|
|
662
|
+
static func objectRecord(_ slot: Slot) -> Data {
|
|
663
|
+
switch ShapeType(rawValue: slot.type) {
|
|
664
|
+
case .path:
|
|
665
|
+
var out = Data([slot.type, 1, slot.color, slot.fillRule])
|
|
666
|
+
for value in slot.params.prefix(3) { appendI16(value, to: &out) }
|
|
667
|
+
out.append(UInt8(slot.path.count & 255)); out.append(UInt8(slot.path.count >> 8))
|
|
668
|
+
out.append(contentsOf: slot.path)
|
|
669
|
+
return out
|
|
670
|
+
case .textInline:
|
|
671
|
+
var out = Data([slot.type, 1, slot.color, 0])
|
|
672
|
+
for value in slot.params.prefix(4) { appendI16(value, to: &out) }
|
|
673
|
+
out.append(UInt8(min(slot.text.count, maxInlineBytes)))
|
|
674
|
+
out.append(contentsOf: slot.text.prefix(maxInlineBytes))
|
|
675
|
+
return out
|
|
676
|
+
case .image, .text, .textCached:
|
|
677
|
+
var out = Data([slot.type, 1, slot.color, 0])
|
|
678
|
+
for value in slot.params.prefix(2) { appendI16(value, to: &out) }
|
|
679
|
+
G2CfwTextureCache.appendU32(Int(slot.asset), to: &out)
|
|
680
|
+
if slot.type == ShapeType.textCached.rawValue { G2CfwTextureCache.appendU32(Int(slot.font), to: &out) }
|
|
681
|
+
return out
|
|
682
|
+
default:
|
|
683
|
+
var out = Data([slot.type, 1, slot.color, slot.width])
|
|
684
|
+
for value in slot.params { appendI16(value, to: &out) }
|
|
685
|
+
return out
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/// `[1][id:u32][version:u32][record]`
|
|
690
|
+
static func putObject(id: UInt32, version: UInt32, record: Data) -> Data {
|
|
691
|
+
var out = Data([1])
|
|
692
|
+
G2CfwTextureCache.appendU32(Int(id), to: &out)
|
|
693
|
+
G2CfwTextureCache.appendU32(Int(version), to: &out)
|
|
694
|
+
out.append(record)
|
|
695
|
+
return out
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/// `[38][epoch 0][request][flags][bg][puts][put entries][count:u16][(id, version) x count][ops]`
|
|
699
|
+
static func showPayload(puts: [Data], refs: [(UInt32, UInt32)], ops: [Data], request: UInt16, tag: Bool = false, bg: UInt8 = 0) -> Data {
|
|
700
|
+
var out = Data([38, 0, 0, UInt8(request & 0xFF), UInt8(request >> 8), tag ? 0x05 : 0x01, bg & 0x0F, UInt8(min(255, puts.count))])
|
|
701
|
+
for put in puts { out.append(put) }
|
|
702
|
+
out.append(UInt8(refs.count & 0xFF)); out.append(UInt8(refs.count >> 8))
|
|
703
|
+
for (id, version) in refs {
|
|
704
|
+
G2CfwTextureCache.appendU32(Int(id), to: &out)
|
|
705
|
+
G2CfwTextureCache.appendU32(Int(version), to: &out)
|
|
706
|
+
}
|
|
707
|
+
for op in ops { out.append(op) }
|
|
708
|
+
return out
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/// `[9][id:u32][angle:i32][pivotX:i16][pivotY:i16][durationMs:u16][curve x4]`
|
|
712
|
+
static func rotationOp(_ slot: Slot, id: UInt32, duration: Int32, curve: [UInt8]) -> Data {
|
|
713
|
+
guard slot.rotatable else { return Data() }
|
|
714
|
+
var out = Data([9])
|
|
715
|
+
G2CfwTextureCache.appendU32(Int(id), to: &out)
|
|
716
|
+
let bits = UInt32(bitPattern: slot.angle)
|
|
717
|
+
for shift in stride(from: 0, through: 24, by: 8) {
|
|
718
|
+
out.append(UInt8((bits >> shift) & 255))
|
|
719
|
+
}
|
|
720
|
+
for value in [UInt16(bitPattern: slot.pivotX), UInt16(bitPattern: slot.pivotY), UInt16(clamping: duration)] {
|
|
721
|
+
out.append(UInt8(value & 255)); out.append(UInt8(value >> 8))
|
|
722
|
+
}
|
|
723
|
+
out.append(contentsOf: curve.prefix(4))
|
|
724
|
+
return out
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/// `[5][id:u32][mask:u16][frames][curve x4][value:i16 per set bit]`
|
|
728
|
+
static func tweenOp(id: UInt32, mask: UInt16, frames: UInt8, curve: [UInt8], values: [Int16]) -> Data {
|
|
729
|
+
var out = Data([5])
|
|
730
|
+
G2CfwTextureCache.appendU32(Int(id), to: &out)
|
|
731
|
+
out.append(UInt8(mask & 0xFF)); out.append(UInt8(mask >> 8)); out.append(frames)
|
|
732
|
+
out.append(contentsOf: curve.prefix(4))
|
|
733
|
+
for value in values { appendI16(value, to: &out) }
|
|
734
|
+
return out
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/// A TWEEN from `old` (what the glasses show) to `new`, or nil when the change
|
|
738
|
+
/// is not a pure geometry/stroke change of the same shape type.
|
|
739
|
+
///
|
|
740
|
+
/// The mask covers every tweenable parameter (plus color and width where the
|
|
741
|
+
/// type allows), not only the ones that changed: the firmware takes an unmasked
|
|
742
|
+
/// parameter's end value from the object's CURRENT value, which is mid-flight
|
|
743
|
+
/// while an earlier tween is still running, so a changed-only mask would freeze
|
|
744
|
+
/// that axis wherever the earlier tween had got to when a second move lands.
|
|
745
|
+
static func tweenOp(from old: Slot, to new: Slot, id: UInt32, frames: UInt8, curve: [UInt8]) -> Data? {
|
|
746
|
+
guard old.type == new.type, old.text == new.text, old.path == new.path, old.fillRule == new.fillRule,
|
|
747
|
+
old.asset == new.asset, old.font == new.font else { return nil }
|
|
748
|
+
let tweenable = tweenableMask(new.type)
|
|
749
|
+
for k in 0 ..< 8 where old.params[k] != new.params[k] {
|
|
750
|
+
guard tweenable & (1 << k) != 0 else { return nil }
|
|
751
|
+
}
|
|
752
|
+
let isTexture = new.type >= ShapeType.image.rawValue && new.type != ShapeType.path.rawValue
|
|
753
|
+
// Textures fade through the options byte's low nibble (the ramp's top
|
|
754
|
+
// color) as long as the option bits stay put; the firmware lerps the
|
|
755
|
+
// whole byte, so a changed option bit would pass through wrong states.
|
|
756
|
+
let textureFade = isTexture && (old.color & 0xF0) == (new.color & 0xF0)
|
|
757
|
+
if old.color != new.color {
|
|
758
|
+
guard !isTexture || textureFade else { return nil }
|
|
759
|
+
}
|
|
760
|
+
if old.width != new.width {
|
|
761
|
+
guard !isTexture else { return nil }
|
|
762
|
+
}
|
|
763
|
+
var mask: UInt16 = tweenable
|
|
764
|
+
var values: [Int16] = []
|
|
765
|
+
for k in 0 ..< 8 where tweenable & (1 << k) != 0 {
|
|
766
|
+
values.append(new.params[k])
|
|
767
|
+
}
|
|
768
|
+
if !isTexture || textureFade {
|
|
769
|
+
mask |= 0x100
|
|
770
|
+
values.append(Int16(new.color))
|
|
771
|
+
}
|
|
772
|
+
if !isTexture && new.type != ShapeType.path.rawValue {
|
|
773
|
+
mask |= 0x200
|
|
774
|
+
values.append(Int16(new.width))
|
|
775
|
+
}
|
|
776
|
+
guard mask != 0 else { return nil }
|
|
777
|
+
return tweenOp(id: id, mask: mask, frames: frames, curve: curve, values: values)
|
|
778
|
+
}
|
|
779
|
+
}
|