@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,673 @@
|
|
|
1
|
+
package com.glassly.bluetoothsdk.sgcs
|
|
2
|
+
|
|
3
|
+
import com.glassly.bluetoothsdk.sgcs.G2CfwTextureCache.Companion.writeI16
|
|
4
|
+
import com.glassly.bluetoothsdk.sgcs.G2CfwTextureCache.Companion.writeU32
|
|
5
|
+
import java.io.ByteArrayOutputStream
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Retained object cache encoder for the Glassly CFW (GLASSLYCFW/38, modes 37-41).
|
|
9
|
+
*
|
|
10
|
+
* Every scene element becomes one or more OBJECTS with stable 32-bit ids
|
|
11
|
+
* (derived from the element id and the object's position within the element):
|
|
12
|
+
* vector shapes map 1:1; default and builtin text becomes one inline TEXT
|
|
13
|
+
* object per line drawn by the glasses' font; mono text becomes TEXT_CACHED
|
|
14
|
+
* runs drawn with a font asset (uploaded whole, once per cache generation);
|
|
15
|
+
* images are rasterized on the phone and drawn as IMAGE objects tiled to the
|
|
16
|
+
* 255 px limit, keyed by their pixels so identical images share one asset.
|
|
17
|
+
*
|
|
18
|
+
* A frame is one SHOW: the ordered (id, version) references of what is on
|
|
19
|
+
* screen, the embedded PUTs for objects the glasses do not hold yet (new, or
|
|
20
|
+
* changed content: a version is a hash of the record), and TWEEN/ROTATE ops for
|
|
21
|
+
* animated changes of geometry the glasses already hold. Objects that leave a
|
|
22
|
+
* frame stay cached on the glasses (and in this mirror) until the firmware
|
|
23
|
+
* evicts them least-recently-used, so a view coming back after a HIDE is pure
|
|
24
|
+
* references. The firmware validates every reference; a MISSING reply names
|
|
25
|
+
* what to redefine ([forget]).
|
|
26
|
+
*
|
|
27
|
+
* One instance mirrors one lens. Element depth is a stereo disparity: a lens
|
|
28
|
+
* mirror encoding with parallax ±1 shifts each element by depth × parallax px,
|
|
29
|
+
* so the renderer keeps a left and a right mirror once a frame uses depth and
|
|
30
|
+
* sends each lens its own SHOW ([G2CfwRenderer]).
|
|
31
|
+
*/
|
|
32
|
+
internal class G2CfwScene {
|
|
33
|
+
|
|
34
|
+
companion object {
|
|
35
|
+
const val MAX_OBJECTS = 256
|
|
36
|
+
const val FRAME_PERIOD_MS = 33.0
|
|
37
|
+
const val TEXT_LINE_HEIGHT = 27
|
|
38
|
+
const val TEXT_INSET = 2
|
|
39
|
+
const val MAX_TEXT_BYTES = 64
|
|
40
|
+
const val MAX_INLINE_BYTES = 128
|
|
41
|
+
const val MAX_TILE_SIZE = 255
|
|
42
|
+
/** Mode-15/20 options: identity ramp, transparent source color 0. */
|
|
43
|
+
const val TEXT_OPTIONS = 0x1F
|
|
44
|
+
const val IMAGE_OPTIONS = 0x0F
|
|
45
|
+
const val ALL_OBJECTS = -1
|
|
46
|
+
|
|
47
|
+
const val TYPE_LINE = 1
|
|
48
|
+
const val TYPE_RECT = 2
|
|
49
|
+
const val TYPE_RECT_FILL = 3
|
|
50
|
+
const val TYPE_CIRCLE = 4
|
|
51
|
+
const val TYPE_CIRCLE_FILL = 5
|
|
52
|
+
const val TYPE_TRI = 6
|
|
53
|
+
const val TYPE_TRI_FILL = 7
|
|
54
|
+
const val TYPE_QUAD = 8
|
|
55
|
+
const val TYPE_QUAD_FILL = 9
|
|
56
|
+
const val TYPE_BEZIER2 = 10
|
|
57
|
+
const val TYPE_BEZIER3 = 11
|
|
58
|
+
const val TYPE_ARC = 12
|
|
59
|
+
const val TYPE_PIE = 13
|
|
60
|
+
const val TYPE_IMAGE = 14
|
|
61
|
+
const val TYPE_TEXT = 15
|
|
62
|
+
const val TYPE_TEXT_CACHED = 16
|
|
63
|
+
const val TYPE_TEXT_INLINE = 17
|
|
64
|
+
const val TYPE_PATH = 18
|
|
65
|
+
|
|
66
|
+
/** Wire codes of the half-block grid font (mode 20 indexes a font by byte, so glyphs need not be ASCII). */
|
|
67
|
+
val BLOCK_CODES: Map<Int, Int> = mapOf(32 to 32, 0x2580 to 65, 0x2584 to 66, 0x2588 to 67)
|
|
68
|
+
|
|
69
|
+
/** The warm-up dot: a hidden two-frame tween sent once per cache generation right after the RESET. */
|
|
70
|
+
val WARM_UP_ID: Int = G2CfwTextureCache.hash31("warm-up")
|
|
71
|
+
val WARM_UP_SHOW: ByteArray by lazy {
|
|
72
|
+
val dot = Slot(TYPE_CIRCLE_FILL, 0, 0, listOf(0, 0, 1))
|
|
73
|
+
showPayload(
|
|
74
|
+
puts = listOf(putObject(WARM_UP_ID, 1, objectRecord(dot))),
|
|
75
|
+
refs = listOf(WARM_UP_ID to 1),
|
|
76
|
+
ops = listOf(tweenOp(WARM_UP_ID, 0x01, 2, intArrayOf(0, 0, 255, 255), listOf(1))),
|
|
77
|
+
request = 0,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
/** Mode 39: freeze, blank and keep everything cached. */
|
|
81
|
+
val HIDE_PAYLOAD: ByteArray = byteArrayOf(39, 0, 0, 0, 0, 0x01, 0)
|
|
82
|
+
/** Mode 41 sub 0: empty both caches and adopt the epoch the link stamps in. */
|
|
83
|
+
val RESET_PAYLOAD: ByteArray = byteArrayOf(41, 0, 0, 0, 0, 0)
|
|
84
|
+
|
|
85
|
+
/** Geometry parameters a TWEEN may interpolate, per type. Asset ids never tween. */
|
|
86
|
+
fun tweenableMask(type: Int): Int =
|
|
87
|
+
when (type) {
|
|
88
|
+
TYPE_PATH -> 0x07
|
|
89
|
+
TYPE_LINE -> 0x0F
|
|
90
|
+
TYPE_RECT, TYPE_RECT_FILL, TYPE_ARC, TYPE_PIE -> 0x1F
|
|
91
|
+
TYPE_CIRCLE, TYPE_CIRCLE_FILL -> 0x07
|
|
92
|
+
TYPE_TRI, TYPE_TRI_FILL, TYPE_BEZIER2 -> 0x3F
|
|
93
|
+
TYPE_QUAD, TYPE_QUAD_FILL, TYPE_BEZIER3 -> 0xFF
|
|
94
|
+
TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> 0x03
|
|
95
|
+
TYPE_TEXT_INLINE -> 0x0F
|
|
96
|
+
else -> 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** FNV-1a 64 of a tile's pixels: the asset key, so identical pixels share one upload. */
|
|
100
|
+
fun pixelHash(pixels: ByteArray): String {
|
|
101
|
+
var hash = -0x340d631b7bdddcdbL // 0xcbf29ce484222325
|
|
102
|
+
for (byte in pixels) {
|
|
103
|
+
hash = hash xor (byte.toLong() and 0xFF)
|
|
104
|
+
hash *= 0x100000001b3L
|
|
105
|
+
}
|
|
106
|
+
return java.lang.Long.toUnsignedString(hash, 36)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fun easingCurve(easing: String): IntArray =
|
|
110
|
+
when (easing) {
|
|
111
|
+
"linear" -> intArrayOf(0, 0, 255, 255)
|
|
112
|
+
"ease-in" -> intArrayOf(107, 0, 255, 255)
|
|
113
|
+
"ease-out" -> intArrayOf(0, 0, 148, 255)
|
|
114
|
+
"ease-in-out" -> intArrayOf(107, 0, 148, 255)
|
|
115
|
+
else -> intArrayOf(64, 26, 64, 255) // CSS "ease"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fun transitionFrames(ms: Int): Int {
|
|
119
|
+
if (ms <= 0) return 0
|
|
120
|
+
val frames = Math.round(ms / FRAME_PERIOD_MS).toInt()
|
|
121
|
+
return frames.coerceIn(2, 255)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary. */
|
|
125
|
+
fun textBytes(line: String, limit: Int = MAX_TEXT_BYTES): ByteArray {
|
|
126
|
+
val out = ByteArrayOutputStream()
|
|
127
|
+
var i = 0
|
|
128
|
+
while (i < line.length) {
|
|
129
|
+
val cp = line.codePointAt(i)
|
|
130
|
+
i += Character.charCount(cp)
|
|
131
|
+
if (cp < 32) continue
|
|
132
|
+
val encoded = String(Character.toChars(cp)).toByteArray(Charsets.UTF_8)
|
|
133
|
+
if (out.size() + encoded.size > limit) break
|
|
134
|
+
out.write(encoded)
|
|
135
|
+
}
|
|
136
|
+
return out.toByteArray()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Object id of the k-th slot of an element: stable across frames and reopenings. */
|
|
140
|
+
fun objectId(elementId: String, k: Int): Int = G2CfwTextureCache.hash31("$elementId#$k")
|
|
141
|
+
|
|
142
|
+
/** Content version of a slot: a hash of the record the glasses store. */
|
|
143
|
+
fun versionOf(slot: Slot): Int = G2CfwTextureCache.hash31(objectRecord(slot))
|
|
144
|
+
|
|
145
|
+
private fun clampCoord(v: Int): Int = v.coerceIn(-1024, 1023)
|
|
146
|
+
|
|
147
|
+
private fun gray(element: SceneElement): Int = if (element.color < 0) 15 else element.color.coerceIn(0, 15)
|
|
148
|
+
|
|
149
|
+
private fun stroke(element: SceneElement): Int = element.border.coerceIn(1, 255)
|
|
150
|
+
|
|
151
|
+
/** The shape record the object cache stores for a slot (shapes.h layouts). */
|
|
152
|
+
fun objectRecord(slot: Slot): ByteArray {
|
|
153
|
+
val out = ByteArrayOutputStream()
|
|
154
|
+
when (slot.type) {
|
|
155
|
+
TYPE_PATH -> {
|
|
156
|
+
out.write(slot.type); out.write(1); out.write(slot.color); out.write(slot.fillRule)
|
|
157
|
+
for (k in 0..2) out.writeI16(slot.params[k])
|
|
158
|
+
out.write(slot.path.size and 0xFF); out.write((slot.path.size ushr 8) and 0xFF)
|
|
159
|
+
out.write(slot.path)
|
|
160
|
+
}
|
|
161
|
+
TYPE_TEXT_INLINE -> {
|
|
162
|
+
out.write(slot.type); out.write(1); out.write(slot.color); out.write(0)
|
|
163
|
+
for (k in 0..3) out.writeI16(slot.params[k])
|
|
164
|
+
val bytes = slot.text.take(MAX_INLINE_BYTES).toByteArray()
|
|
165
|
+
out.write(bytes.size); out.write(bytes)
|
|
166
|
+
}
|
|
167
|
+
TYPE_IMAGE, TYPE_TEXT, TYPE_TEXT_CACHED -> {
|
|
168
|
+
out.write(slot.type); out.write(1); out.write(slot.color); out.write(0)
|
|
169
|
+
for (k in 0..1) out.writeI16(slot.params[k])
|
|
170
|
+
out.writeU32(slot.asset)
|
|
171
|
+
if (slot.type == TYPE_TEXT_CACHED) out.writeU32(slot.font)
|
|
172
|
+
}
|
|
173
|
+
else -> {
|
|
174
|
+
out.write(slot.type); out.write(1); out.write(slot.color); out.write(slot.width)
|
|
175
|
+
for (k in 0 until 8) out.writeI16(slot.params[k])
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out.toByteArray()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `[1][id:u32][version:u32][record]` */
|
|
182
|
+
fun putObject(id: Int, version: Int, record: ByteArray): ByteArray {
|
|
183
|
+
val out = ByteArrayOutputStream()
|
|
184
|
+
out.write(1); out.writeU32(id); out.writeU32(version); out.write(record)
|
|
185
|
+
return out.toByteArray()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** `[38][epoch 0][request][flags][bg][puts][put entries][count:u16][(id, version) x count][ops]` */
|
|
189
|
+
fun showPayload(puts: List<ByteArray>, refs: List<Pair<Int, Int>>, ops: List<ByteArray>, request: Int, tag: Boolean = false, bg: Int = 0): ByteArray {
|
|
190
|
+
val out = ByteArrayOutputStream()
|
|
191
|
+
out.write(38); out.write(0); out.write(0)
|
|
192
|
+
out.write(request and 0xFF); out.write((request ushr 8) and 0xFF)
|
|
193
|
+
out.write(if (tag) 0x05 else 0x01); out.write(bg and 0x0F); out.write(minOf(255, puts.size))
|
|
194
|
+
for (put in puts) out.write(put)
|
|
195
|
+
out.write(refs.size and 0xFF); out.write((refs.size ushr 8) and 0xFF)
|
|
196
|
+
for ((id, version) in refs) { out.writeU32(id); out.writeU32(version) }
|
|
197
|
+
for (op in ops) out.write(op)
|
|
198
|
+
return out.toByteArray()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `[9][id:u32][angle:i32][pivotX:i16][pivotY:i16][durationMs:u16][curve x4]` */
|
|
202
|
+
fun rotationOp(slot: Slot, id: Int, duration: Int, curve: IntArray): ByteArray {
|
|
203
|
+
if (!slot.rotatable) return ByteArray(0)
|
|
204
|
+
val out = ByteArrayOutputStream()
|
|
205
|
+
out.write(9); out.writeU32(id); out.writeU32(slot.angle)
|
|
206
|
+
out.writeI16(slot.pivotX); out.writeI16(slot.pivotY)
|
|
207
|
+
val ms = duration.coerceIn(0, 65535)
|
|
208
|
+
out.write(ms and 0xFF); out.write((ms ushr 8) and 0xFF)
|
|
209
|
+
for (i in 0..3) out.write(curve[i])
|
|
210
|
+
return out.toByteArray()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `[5][id:u32][mask:u16][frames][curve x4][value:i16 per set bit]` */
|
|
214
|
+
fun tweenOp(id: Int, mask: Int, frames: Int, curve: IntArray, values: List<Int>): ByteArray {
|
|
215
|
+
val out = ByteArrayOutputStream()
|
|
216
|
+
out.write(5); out.writeU32(id)
|
|
217
|
+
out.write(mask and 0xFF); out.write((mask ushr 8) and 0xFF); out.write(frames.coerceIn(0, 255))
|
|
218
|
+
for (i in 0..3) out.write(curve[i])
|
|
219
|
+
for (value in values) out.writeI16(value)
|
|
220
|
+
return out.toByteArray()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A TWEEN from `old` (what the glasses show) to `new`, or null when the change
|
|
225
|
+
* is not a pure geometry/stroke change of the same shape type. The mask covers
|
|
226
|
+
* every tweenable parameter (plus color and width where the type allows), not
|
|
227
|
+
* only the ones that changed: the firmware takes an unmasked parameter's end
|
|
228
|
+
* value from the object's CURRENT value, which is mid-flight while an earlier
|
|
229
|
+
* tween is still running.
|
|
230
|
+
*/
|
|
231
|
+
fun tweenOp(old: Slot, new: Slot, id: Int, frames: Int, curve: IntArray): ByteArray? {
|
|
232
|
+
if (old.type != new.type || !old.text.contentEquals(new.text) || !old.path.contentEquals(new.path) ||
|
|
233
|
+
old.fillRule != new.fillRule || old.asset != new.asset || old.font != new.font
|
|
234
|
+
) return null
|
|
235
|
+
val tweenable = tweenableMask(new.type)
|
|
236
|
+
for (k in 0 until 8) {
|
|
237
|
+
if (old.params[k] != new.params[k] && tweenable and (1 shl k) == 0) return null
|
|
238
|
+
}
|
|
239
|
+
val isTexture = new.type >= TYPE_IMAGE && new.type != TYPE_PATH
|
|
240
|
+
val textureFade = isTexture && (old.color and 0xF0) == (new.color and 0xF0)
|
|
241
|
+
if (old.color != new.color && isTexture && !textureFade) return null
|
|
242
|
+
if (old.width != new.width && isTexture) return null
|
|
243
|
+
var mask = tweenable
|
|
244
|
+
val values = mutableListOf<Int>()
|
|
245
|
+
for (k in 0 until 8) if (tweenable and (1 shl k) != 0) values.add(new.params[k])
|
|
246
|
+
if (!isTexture || textureFade) { mask = mask or 0x100; values.add(new.color) }
|
|
247
|
+
if (!isTexture && new.type != TYPE_PATH) { mask = mask or 0x200; values.add(new.width) }
|
|
248
|
+
if (mask == 0) return null
|
|
249
|
+
return tweenOp(id, mask, frames, curve, values)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Changing bitmaps benefit from compressed dirty rectangles; transitions still need objects. */
|
|
253
|
+
fun prefersRaster(frame: SceneFrame): Boolean =
|
|
254
|
+
frame.elements.isNotEmpty() && frame.elements.all { it.type == "image" && it.transitionMs == 0 && it.depth == 0 }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One object's record in decoded form: type, 4-bit color (or options byte), stroke width,
|
|
259
|
+
* eight int16 parameters, inline text bytes, path commands, and asset ids.
|
|
260
|
+
*/
|
|
261
|
+
class Slot(
|
|
262
|
+
val type: Int, val color: Int, val width: Int, params: List<Int>,
|
|
263
|
+
val text: ByteArray = ByteArray(0), val path: ByteArray = ByteArray(0), val fillRule: Int = 0,
|
|
264
|
+
val asset: Int = 0, val font: Int = 0,
|
|
265
|
+
) {
|
|
266
|
+
var angle: Int = 0
|
|
267
|
+
var pivotX: Int = 0
|
|
268
|
+
var pivotY: Int = 0
|
|
269
|
+
val rotatable: Boolean get() = type <= 13 || type == TYPE_PATH
|
|
270
|
+
val params: IntArray = IntArray(8) { params.getOrElse(it) { 0 } }
|
|
271
|
+
|
|
272
|
+
override fun equals(other: Any?): Boolean =
|
|
273
|
+
other is Slot && sameGeometry(other) && other.angle == angle && other.pivotX == pivotX && other.pivotY == pivotY
|
|
274
|
+
|
|
275
|
+
fun sameGeometry(other: Slot): Boolean = type == other.type && color == other.color && width == other.width &&
|
|
276
|
+
params.contentEquals(other.params) && text.contentEquals(other.text) && path.contentEquals(other.path) &&
|
|
277
|
+
fillRule == other.fillRule && asset == other.asset && font == other.font
|
|
278
|
+
|
|
279
|
+
override fun hashCode(): Int =
|
|
280
|
+
(((type * 31 + color) * 31 + width) * 31 + params.contentHashCode()) * 31 + text.contentHashCode()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** What this mirror knows the glasses hold for one object. */
|
|
284
|
+
class Known(var version: Int, var definition: Slot, var state: Slot?, var animEndsAt: Long)
|
|
285
|
+
|
|
286
|
+
class Encoded(val payload: ByteArray, val uploads: List<ByteArray>, val changed: Boolean = true, val puts: Int = 0, val hits: Int = 0, val tweens: Int = 0)
|
|
287
|
+
|
|
288
|
+
class MonoFace(
|
|
289
|
+
val metrics: (Int) -> Pair<Int, Int>,
|
|
290
|
+
val glyph: (Int, Int, Int, Int) -> ByteArray?,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
var generation = -1
|
|
294
|
+
private set
|
|
295
|
+
val known = mutableMapOf<Int, Known>()
|
|
296
|
+
var active: List<Int> = emptyList()
|
|
297
|
+
private set
|
|
298
|
+
var objectIds: List<List<Int>> = emptyList()
|
|
299
|
+
private set
|
|
300
|
+
|
|
301
|
+
fun reset(generation: Int) {
|
|
302
|
+
this.generation = generation
|
|
303
|
+
known.clear()
|
|
304
|
+
active = emptyList()
|
|
305
|
+
objectIds = emptyList()
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Residency is unknown (a send failed): the next frame redefines everything it shows. */
|
|
309
|
+
fun invalidate() {
|
|
310
|
+
known.clear()
|
|
311
|
+
active = emptyList()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The panel went blank: objects animating right now froze mid-flight, so they are re-snapped when shown again. */
|
|
315
|
+
fun hidden() {
|
|
316
|
+
val now = System.currentTimeMillis()
|
|
317
|
+
for (id in active) known[id]?.let { if (it.animEndsAt > now) it.state = null }
|
|
318
|
+
active = emptyList()
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The glasses reported these object ids absent (or at another version). */
|
|
322
|
+
fun forget(objectIds: Collection<Int>) {
|
|
323
|
+
for (id in objectIds) known.remove(id)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Become a copy of [other]: the second lens mirror starts from the shared
|
|
328
|
+
* state the moment the lenses diverge (the first frame with element depth).
|
|
329
|
+
*/
|
|
330
|
+
fun adopt(other: G2CfwScene) {
|
|
331
|
+
generation = other.generation
|
|
332
|
+
known.clear()
|
|
333
|
+
for ((id, k) in other.known) known[id] = Known(k.version, k.definition, k.state, k.animEndsAt)
|
|
334
|
+
active = other.active
|
|
335
|
+
objectIds = other.objectIds
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private fun point(element: SceneElement, i: Int, ox: Int, oy: Int): Pair<Int, Int> {
|
|
339
|
+
if (element.points.size < 2 * i + 2) return 0 to 0
|
|
340
|
+
return clampCoord(element.points[2 * i] + ox) to clampCoord(element.points[2 * i + 1] + oy)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private fun circleGeometry(element: SceneElement, ox: Int, oy: Int): Triple<Int, Int, Int> {
|
|
344
|
+
val r = maxOf(0, (minOf(element.w, element.h) - 1) / 2)
|
|
345
|
+
return Triple(clampCoord(element.x + element.w / 2 + ox), clampCoord(element.y + element.h / 2 + oy), r)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Slots for one element, or null when the asset store cannot hold its content. */
|
|
349
|
+
fun slotsFor(
|
|
350
|
+
element: SceneElement,
|
|
351
|
+
ox: Int,
|
|
352
|
+
oy: Int,
|
|
353
|
+
inlineText: Boolean,
|
|
354
|
+
cache: G2CfwTextureCache,
|
|
355
|
+
rasterize: (SceneElement, Int, Int) -> ByteArray?,
|
|
356
|
+
mono: MonoFace? = null,
|
|
357
|
+
): List<Slot>? {
|
|
358
|
+
val x = clampCoord(element.x + ox)
|
|
359
|
+
val y = clampCoord(element.y + oy)
|
|
360
|
+
val w = element.w.coerceIn(0, 2047)
|
|
361
|
+
val h = element.h.coerceIn(0, 2047)
|
|
362
|
+
val color = gray(element)
|
|
363
|
+
val radius = element.radius.coerceIn(0, 2047)
|
|
364
|
+
return when (element.type) {
|
|
365
|
+
"svg" -> {
|
|
366
|
+
if (element.path.isEmpty() || element.path.size > 8192 || element.pathScale !in 0..2048) return null
|
|
367
|
+
listOf(Slot(TYPE_PATH, color, 0, listOf(x, y, element.pathScale), path = element.path.map { it.toByte() }.toByteArray(), fillRule = element.fillRule))
|
|
368
|
+
}
|
|
369
|
+
"rect" -> {
|
|
370
|
+
val out = mutableListOf<Slot>()
|
|
371
|
+
if (element.fill) {
|
|
372
|
+
out.add(Slot(TYPE_RECT_FILL, color, 0, listOf(x, y, w, h, radius)))
|
|
373
|
+
if (element.border > 0) out.add(Slot(TYPE_RECT, color, stroke(element), listOf(x, y, w, h, radius)))
|
|
374
|
+
} else {
|
|
375
|
+
out.add(Slot(TYPE_RECT, color, stroke(element), listOf(x, y, w, h, radius)))
|
|
376
|
+
}
|
|
377
|
+
out
|
|
378
|
+
}
|
|
379
|
+
"circle" -> {
|
|
380
|
+
val (cx, cy, r) = circleGeometry(element, ox, oy)
|
|
381
|
+
if (element.fill) listOf(Slot(TYPE_CIRCLE_FILL, color, 0, listOf(cx, cy, r)))
|
|
382
|
+
else listOf(Slot(TYPE_CIRCLE, color, stroke(element), listOf(cx, cy, r)))
|
|
383
|
+
}
|
|
384
|
+
"arc", "pie" -> {
|
|
385
|
+
val (cx, cy, r) = circleGeometry(element, ox, oy)
|
|
386
|
+
val a0 = clampCoord(element.startDeg)
|
|
387
|
+
val a1 = clampCoord(element.endDeg)
|
|
388
|
+
if (element.type == "pie") listOf(Slot(TYPE_PIE, color, 0, listOf(cx, cy, r, a0, a1)))
|
|
389
|
+
else listOf(Slot(TYPE_ARC, color, stroke(element), listOf(cx, cy, r, a0, a1)))
|
|
390
|
+
}
|
|
391
|
+
"line" -> {
|
|
392
|
+
val (x1, y1) = point(element, 0, ox, oy)
|
|
393
|
+
val (x2, y2) = point(element, 1, ox, oy)
|
|
394
|
+
listOf(Slot(TYPE_LINE, color, stroke(element), listOf(x1, y1, x2, y2)))
|
|
395
|
+
}
|
|
396
|
+
"triangle", "quad" -> {
|
|
397
|
+
val n = if (element.type == "triangle") 3 else 4
|
|
398
|
+
val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
|
|
399
|
+
val filled = if (n == 3) TYPE_TRI_FILL else TYPE_QUAD_FILL
|
|
400
|
+
val stroked = if (n == 3) TYPE_TRI else TYPE_QUAD
|
|
401
|
+
if (element.fill) listOf(Slot(filled, color, 0, params))
|
|
402
|
+
else listOf(Slot(stroked, color, stroke(element), params))
|
|
403
|
+
}
|
|
404
|
+
"bezier" -> {
|
|
405
|
+
val n = element.points.size / 2
|
|
406
|
+
if (n != 3 && n != 4) return emptyList()
|
|
407
|
+
val params = (0 until n).flatMap { point(element, it, ox, oy).toList() }
|
|
408
|
+
listOf(Slot(if (n == 3) TYPE_BEZIER2 else TYPE_BEZIER3, color, stroke(element), params))
|
|
409
|
+
}
|
|
410
|
+
"text" ->
|
|
411
|
+
when {
|
|
412
|
+
element.font == "mono" ->
|
|
413
|
+
if (mono == null) rasterSlots(element, x, y, cache, rasterize)
|
|
414
|
+
else monoTextSlots(element, x, y, cache, mono, rasterize)
|
|
415
|
+
inlineText -> inlineTextSlots(element, x, y)
|
|
416
|
+
element.font == "builtin" -> builtinTextSlots(element, x, y, cache)
|
|
417
|
+
else -> rasterSlots(element, x, y, cache, rasterize)
|
|
418
|
+
}
|
|
419
|
+
"image" -> rasterSlots(element, x, y, cache, rasterize)
|
|
420
|
+
else -> emptyList()
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** One TEXT_INLINE object per line, drawn by the glasses' font at the phone-raster 2 px inset, clipped to the box. */
|
|
425
|
+
private fun inlineTextSlots(element: SceneElement, x: Int, y: Int): List<Slot> {
|
|
426
|
+
val out = mutableListOf<Slot>()
|
|
427
|
+
val w = element.w.coerceIn(0, 2047)
|
|
428
|
+
val h = element.h.coerceIn(0, 2047)
|
|
429
|
+
if (element.border > 0) {
|
|
430
|
+
out.add(Slot(TYPE_RECT, gray(element), stroke(element), listOf(x, y, w, h, element.radius.coerceIn(0, 2047))))
|
|
431
|
+
}
|
|
432
|
+
val options = 0x10 or gray(element)
|
|
433
|
+
val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
|
|
434
|
+
val lines = (element.text ?: "").split("\n").take(maxLines)
|
|
435
|
+
for ((index, line) in lines.withIndex()) {
|
|
436
|
+
val bytes = textBytes(line, MAX_INLINE_BYTES)
|
|
437
|
+
if (bytes.isEmpty()) continue
|
|
438
|
+
val lineY = y + TEXT_INSET + index * TEXT_LINE_HEIGHT
|
|
439
|
+
val clipW = maxOf(0, element.w - TEXT_INSET).coerceAtMost(2047)
|
|
440
|
+
val clipH = maxOf(0, element.h - TEXT_INSET - index * TEXT_LINE_HEIGHT).coerceAtMost(2047)
|
|
441
|
+
out.add(Slot(TYPE_TEXT_INLINE, options, 0, listOf(clampCoord(x + TEXT_INSET), clampCoord(lineY), clipW, clipH, bytes.size), bytes))
|
|
442
|
+
}
|
|
443
|
+
return out
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private fun builtinTextSlots(element: SceneElement, x: Int, y: Int, cache: G2CfwTextureCache): List<Slot>? {
|
|
447
|
+
val out = mutableListOf<Slot>()
|
|
448
|
+
if (element.border > 0) {
|
|
449
|
+
out.add(Slot(TYPE_RECT, 15, stroke(element), listOf(x, y, element.w.coerceIn(0, 2047), element.h.coerceIn(0, 2047), element.radius.coerceIn(0, 2047))))
|
|
450
|
+
}
|
|
451
|
+
val maxLines = maxOf(1, element.h / TEXT_LINE_HEIGHT)
|
|
452
|
+
val lines = (element.text ?: "").split("\n").take(maxLines)
|
|
453
|
+
for ((index, line) in lines.withIndex()) {
|
|
454
|
+
val bytes = textBytes(line)
|
|
455
|
+
if (bytes.isEmpty()) continue
|
|
456
|
+
val asset = cache.ensureBytes("t:$line", bytes) ?: return null
|
|
457
|
+
out.add(Slot(TYPE_TEXT, TEXT_OPTIONS, 0, listOf(x, clampCoord(y + index * TEXT_LINE_HEIGHT)), asset = asset))
|
|
458
|
+
}
|
|
459
|
+
return out
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Mono text as TEXT_CACHED runs: each line is a string asset drawn with the font asset for
|
|
464
|
+
* its size, so glyphs upload once per generation and a changed string costs only its bytes.
|
|
465
|
+
* Cells sit on the raster path's grid (2 px inset, partial edge cells kept); runs split at
|
|
466
|
+
* the 64-byte limit. A separate font maps half-block grids to printable wire bytes; other
|
|
467
|
+
* non-ASCII text keeps the image-tile path.
|
|
468
|
+
*/
|
|
469
|
+
private fun monoTextSlots(
|
|
470
|
+
element: SceneElement,
|
|
471
|
+
x: Int,
|
|
472
|
+
y: Int,
|
|
473
|
+
cache: G2CfwTextureCache,
|
|
474
|
+
mono: MonoFace,
|
|
475
|
+
rasterize: (SceneElement, Int, Int) -> ByteArray?,
|
|
476
|
+
): List<Slot>? {
|
|
477
|
+
val (cellW, lineH) = mono.metrics(element.fontSize)
|
|
478
|
+
if (cellW <= 0 || lineH <= 0) return emptyList()
|
|
479
|
+
val cols = maxOf(0, (element.w - TEXT_INSET + cellW - 1) / cellW)
|
|
480
|
+
val rows = maxOf(0, (element.h - TEXT_INSET + lineH - 1) / lineH)
|
|
481
|
+
val lines = (element.text ?: "").split('\n').take(rows).map { it.codePoints().toArray().take(cols) }
|
|
482
|
+
val blockGrid = lines.any { line -> line.any { it > 126 } } && lines.all { line -> line.all { BLOCK_CODES.containsKey(it) } }
|
|
483
|
+
if (!blockGrid && lines.any { line -> line.any { it < 32 || it > 126 } }) return rasterSlots(element, x, y, cache, rasterize)
|
|
484
|
+
val variant = if (blockGrid) "blocks" else "ascii"
|
|
485
|
+
val codes = if (blockGrid) listOf(32, 65, 66, 67) else (32..126).toList()
|
|
486
|
+
val scalarFor: (Int) -> Int? = { code -> if (blockGrid) BLOCK_CODES.entries.firstOrNull { it.value == code }?.key else code }
|
|
487
|
+
val font = cache.font(element.fontSize, cellW, lineH, variant, codes) { code ->
|
|
488
|
+
scalarFor(code)?.let { mono.glyph(it, element.fontSize, cellW, lineH) }
|
|
489
|
+
} ?: return null
|
|
490
|
+
// Opaque cells (options bit 4 clear): the grid owns its cells, as the raster path's blits do.
|
|
491
|
+
val options = gray(element)
|
|
492
|
+
val out = mutableListOf<Slot>()
|
|
493
|
+
for ((row, line) in lines.withIndex()) {
|
|
494
|
+
if (line.isEmpty()) continue
|
|
495
|
+
val lineY = clampCoord(y + TEXT_INSET + row * lineH)
|
|
496
|
+
var start = 0
|
|
497
|
+
while (start < line.size) {
|
|
498
|
+
val run = line.subList(start, minOf(line.size, start + MAX_TEXT_BYTES))
|
|
499
|
+
val bytes = ByteArray(run.size) { (if (blockGrid) BLOCK_CODES[run[it]]!! else run[it]).toByte() }
|
|
500
|
+
// A glyph the font could not raster keeps the tile path for the element.
|
|
501
|
+
if (bytes.any { val c = it.toInt() and 0xFF; c != 32 && c !in font.glyphs }) return rasterSlots(element, x, y, cache, rasterize)
|
|
502
|
+
val text = String(run.toIntArray(), 0, run.size)
|
|
503
|
+
val asset = cache.ensureBytes("t:$variant:$text", bytes) ?: return null
|
|
504
|
+
out.add(Slot(TYPE_TEXT_CACHED, options, 0, listOf(clampCoord(x + TEXT_INSET + start * cellW), lineY), asset = asset, font = font.id))
|
|
505
|
+
start += run.size
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return out
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private fun rasterSlots(
|
|
512
|
+
element: SceneElement,
|
|
513
|
+
x: Int,
|
|
514
|
+
y: Int,
|
|
515
|
+
cache: G2CfwTextureCache,
|
|
516
|
+
rasterize: (SceneElement, Int, Int) -> ByteArray?,
|
|
517
|
+
): List<Slot>? {
|
|
518
|
+
val w = element.w
|
|
519
|
+
val h = element.h
|
|
520
|
+
if (w <= 0 || h <= 0) return emptyList()
|
|
521
|
+
val gray = rasterize(element, w, h)?.takeIf { it.size == w * h } ?: return emptyList()
|
|
522
|
+
// An image's ink is the ramp's top color; rasterized text keeps its ink in the pixels.
|
|
523
|
+
val options = if (element.type == "image") gray(element) else IMAGE_OPTIONS
|
|
524
|
+
val out = mutableListOf<Slot>()
|
|
525
|
+
var ty = 0
|
|
526
|
+
while (ty < h) {
|
|
527
|
+
val th = minOf(MAX_TILE_SIZE, h - ty)
|
|
528
|
+
var tx = 0
|
|
529
|
+
while (tx < w) {
|
|
530
|
+
val tw = minOf(MAX_TILE_SIZE, w - tx)
|
|
531
|
+
val tile = ByteArray(tw * th)
|
|
532
|
+
for (row in 0 until th) System.arraycopy(gray, (ty + row) * w + tx, tile, row * tw, tw)
|
|
533
|
+
val key = "px:${pixelHash(tile)}@${tw}x$th"
|
|
534
|
+
val asset = cache.ensureImage(key, tile, tw, th) ?: return null
|
|
535
|
+
out.add(Slot(TYPE_IMAGE, options, 0, listOf(clampCoord(x + tx), clampCoord(y + ty)), asset = asset))
|
|
536
|
+
tx += tw
|
|
537
|
+
}
|
|
538
|
+
ty += th
|
|
539
|
+
}
|
|
540
|
+
return out
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private class Desired(val id: String, val slots: List<Slot>, val frames: Int, val curve: IntArray, val duration: Int)
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* One SHOW (embedded object PUTs, references in paint order, ops) and the asset PUT
|
|
547
|
+
* messages that must precede it. Null when the frame does not fit the object table or
|
|
548
|
+
* the asset store; the caller then falls back to the raster path. `tag` (the host frame
|
|
549
|
+
* number) becomes the SHOW's request id with the TAG flag, so settings field 129 reports
|
|
550
|
+
* it once the scene settles.
|
|
551
|
+
*/
|
|
552
|
+
fun encode(
|
|
553
|
+
frame: SceneFrame,
|
|
554
|
+
originX: Int,
|
|
555
|
+
originY: Int,
|
|
556
|
+
cache: G2CfwTextureCache,
|
|
557
|
+
rasterize: (SceneElement, Int, Int) -> ByteArray?,
|
|
558
|
+
inlineText: Boolean = false,
|
|
559
|
+
mono: MonoFace? = null,
|
|
560
|
+
tag: Int? = null,
|
|
561
|
+
/** +1 for the left lens mirror, -1 for the right: each element shifts by depth × parallax px; 0 draws flat. */
|
|
562
|
+
parallax: Int = 0,
|
|
563
|
+
): Encoded? {
|
|
564
|
+
val desired = mutableListOf<Desired>()
|
|
565
|
+
for (element in frame.elements) {
|
|
566
|
+
val ox = originX + element.depth * parallax
|
|
567
|
+
val slots = slotsFor(element, ox, originY, inlineText, cache, rasterize, mono) ?: return null
|
|
568
|
+
slots.filter { it.rotatable }.forEach { slot ->
|
|
569
|
+
slot.angle = element.rotationQ8
|
|
570
|
+
slot.pivotX = ((element.pivotX ?: (element.x + element.w / 2)) + ox).coerceIn(-32768, 32767)
|
|
571
|
+
|
|
572
|
+
slot.pivotY = ((element.pivotY ?: (element.y + element.h / 2)) + originY).coerceIn(-32768, 32767)
|
|
573
|
+
}
|
|
574
|
+
desired.add(Desired(element.id, slots, transitionFrames(element.transitionMs), easingCurve(element.easing), element.transitionMs))
|
|
575
|
+
}
|
|
576
|
+
if (desired.sumOf { it.slots.size } > MAX_OBJECTS) return null
|
|
577
|
+
|
|
578
|
+
val now = System.currentTimeMillis()
|
|
579
|
+
val puts = mutableListOf<ByteArray>()
|
|
580
|
+
val refs = mutableListOf<Pair<Int, Int>>()
|
|
581
|
+
val ops = mutableListOf<ByteArray>()
|
|
582
|
+
var putCount = 0
|
|
583
|
+
var hits = 0
|
|
584
|
+
var tweens = 0
|
|
585
|
+
val ids = mutableListOf<List<Int>>()
|
|
586
|
+
val seen = mutableSetOf<Int>()
|
|
587
|
+
// Only objects the previous frame showed may animate: one coming back to
|
|
588
|
+
// the frame appears where it is asked to be, not tweened from where it left.
|
|
589
|
+
val onScreen = active.toSet()
|
|
590
|
+
for (item in desired) {
|
|
591
|
+
val mine = mutableListOf<Int>()
|
|
592
|
+
for ((k, slot) in item.slots.withIndex()) {
|
|
593
|
+
val id = objectId(item.id, k)
|
|
594
|
+
if (!seen.add(id)) continue
|
|
595
|
+
mine.add(id)
|
|
596
|
+
val version = versionOf(slot)
|
|
597
|
+
fun put() {
|
|
598
|
+
puts.add(putObject(id, version, objectRecord(slot)))
|
|
599
|
+
if (slot.angle != 0) ops.add(rotationOp(slot, id, 0, item.curve))
|
|
600
|
+
putCount += 1
|
|
601
|
+
known[id] = Known(version, slot, slot, 0)
|
|
602
|
+
refs.add(id to version)
|
|
603
|
+
}
|
|
604
|
+
val k0 = known[id]
|
|
605
|
+
if (k0 == null) { put(); continue }
|
|
606
|
+
val animate = id in onScreen
|
|
607
|
+
val current = k0.state
|
|
608
|
+
if (current != null) {
|
|
609
|
+
if (current == slot) { hits += 1; refs.add(id to k0.version); continue }
|
|
610
|
+
val rotationChanged = current.angle != slot.angle ||
|
|
611
|
+
((current.angle != 0 || slot.angle != 0) && (current.pivotX != slot.pivotX || current.pivotY != slot.pivotY))
|
|
612
|
+
if (current.sameGeometry(slot)) {
|
|
613
|
+
if (rotationChanged) {
|
|
614
|
+
val duration = if (animate) item.duration else 0
|
|
615
|
+
ops.add(rotationOp(slot, id, duration, item.curve))
|
|
616
|
+
k0.animEndsAt = now + duration
|
|
617
|
+
}
|
|
618
|
+
k0.state = slot
|
|
619
|
+
hits += 1
|
|
620
|
+
refs.add(id to k0.version)
|
|
621
|
+
continue
|
|
622
|
+
}
|
|
623
|
+
if (animate && item.frames >= 2) {
|
|
624
|
+
val tween = tweenOp(current, slot, id, item.frames, item.curve)
|
|
625
|
+
if (tween != null) {
|
|
626
|
+
ops.add(tween)
|
|
627
|
+
if (rotationChanged) ops.add(rotationOp(slot, id, item.duration, item.curve))
|
|
628
|
+
tweens += 1
|
|
629
|
+
k0.state = slot
|
|
630
|
+
k0.animEndsAt = now + (item.frames * FRAME_PERIOD_MS).toLong()
|
|
631
|
+
refs.add(id to k0.version)
|
|
632
|
+
continue
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// The glasses hold this exact definition and earlier ops only moved it:
|
|
636
|
+
// a same-version PUT is a no-op there, so snap the state instead.
|
|
637
|
+
if (k0.version == version) {
|
|
638
|
+
val snap = tweenOp(current, slot, id, 0, item.curve)
|
|
639
|
+
if (snap != null) {
|
|
640
|
+
ops.add(snap)
|
|
641
|
+
if (rotationChanged) ops.add(rotationOp(slot, id, 0, item.curve))
|
|
642
|
+
k0.state = slot
|
|
643
|
+
k0.animEndsAt = 0
|
|
644
|
+
hits += 1
|
|
645
|
+
refs.add(id to k0.version)
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
put()
|
|
650
|
+
continue
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Frozen mid-flight by a hide: snap it back to the geometry it should show.
|
|
654
|
+
val snap = tweenOp(k0.definition, slot, id, 0, item.curve)
|
|
655
|
+
if (snap != null) {
|
|
656
|
+
ops.add(snap)
|
|
657
|
+
if (slot.angle != 0 || k0.definition.angle != 0) ops.add(rotationOp(slot, id, 0, item.curve))
|
|
658
|
+
k0.state = slot
|
|
659
|
+
hits += 1
|
|
660
|
+
refs.add(id to k0.version)
|
|
661
|
+
continue
|
|
662
|
+
}
|
|
663
|
+
put()
|
|
664
|
+
}
|
|
665
|
+
ids.add(mine)
|
|
666
|
+
}
|
|
667
|
+
val listChanged = refs.map { it.first } != active
|
|
668
|
+
active = refs.map { it.first }
|
|
669
|
+
objectIds = ids
|
|
670
|
+
val payload = showPayload(puts, refs, ops, request = (tag ?: 0) and 0xFFFF, tag = (tag ?: 0) > 0)
|
|
671
|
+
return Encoded(payload, cache.takeUploadPayloads(), listChanged || puts.isNotEmpty() || ops.isNotEmpty(), putCount, hits, tweens)
|
|
672
|
+
}
|
|
673
|
+
}
|