@glassly/bluetooth-sdk 0.1.0-dev.105 → 0.1.0-dev.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  2. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +17 -1
  3. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
  4. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +66 -3
  5. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +35 -0
  6. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +415 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +10 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +2 -2
  9. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +16 -2
  10. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +2 -2
  11. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +175 -0
  12. package/build/BluetoothSdk.types.d.ts +2 -0
  13. package/build/BluetoothSdk.types.d.ts.map +1 -1
  14. package/build/BluetoothSdk.types.js.map +1 -1
  15. package/build/generated/releaseMetadata.js +3 -3
  16. package/build/generated/releaseMetadata.js.map +1 -1
  17. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  18. package/ios/Source/DeviceManager.swift +16 -2
  19. package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
  20. package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  21. package/ios/Source/sgcs/G2.swift +48 -3
  22. package/ios/Source/sgcs/G2CfwRenderer.swift +37 -0
  23. package/ios/Source/sgcs/G2CfwScene.swift +394 -0
  24. package/ios/Source/sgcs/G2CfwTextureCache.swift +10 -0
  25. package/ios/Source/sgcs/G2Flasher.swift +2 -2
  26. package/ios/Source/sgcs/SGCManager.swift +34 -3
  27. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +187 -0
  28. package/package.json +1 -1
  29. package/src/BluetoothSdk.types.ts +2 -0
  30. package/src/generated/releaseMetadata.ts +3 -3
@@ -0,0 +1,394 @@
1
+ import Foundation
2
+
3
+ /// Retained vector scene for the Glassly CFW (firmware modes 16-18).
4
+ ///
5
+ /// Every scene element becomes one or more firmware slots (paint order = slot
6
+ /// order): vector shapes map 1:1, builtin-font text becomes one TEXT slot per
7
+ /// line with its bytes in the texture cache, and everything else (proportional
8
+ /// text, images) is rasterized on the phone and drawn as cached IMAGE slots
9
+ /// tiled to the 255 px slot limit. Frames diff against the last committed
10
+ /// layout: unchanged slots cost nothing, geometry changes on an element with a
11
+ /// transition become firmware TWEENs, and anything else is re-SET. Slot indices
12
+ /// stay stable per element id; a reorder or an over-budget frame repacks.
13
+ final class G2CfwScene {
14
+ static let maxSlots = 128
15
+ static let recordBytes = 20
16
+ static let framePeriodMs = 33.0
17
+ static let textLineHeight = 27
18
+ static let maxTextBytes = 64
19
+ static let maxTileSize = 255
20
+ /// Mode-13/15 options: identity ramp, transparent source color 0.
21
+ static let textOptions: UInt8 = 0x1F
22
+ static let imageOptions: UInt8 = 0x0F
23
+
24
+ enum ShapeType: UInt8 {
25
+ case line = 1, rect, rectFill, circle, circleFill, tri, triFill, quad, quadFill
26
+ case bezier2, bezier3, arc, pie, image, text, textCached
27
+ }
28
+
29
+ struct Slot: Equatable {
30
+ var type: UInt8
31
+ var color: UInt8
32
+ var width: UInt8
33
+ var params: [Int16]
34
+
35
+ init(_ type: ShapeType, color: UInt8, width: UInt8 = 0, _ params: [Int16]) {
36
+ self.type = type.rawValue
37
+ self.color = color
38
+ self.width = width
39
+ var padded = params
40
+ while padded.count < 8 { padded.append(0) }
41
+ self.params = Array(padded.prefix(8))
42
+ }
43
+ }
44
+
45
+ struct ElementState {
46
+ let id: String
47
+ let contentHash: String
48
+ var slots: [Slot]
49
+ var indices: [Int]
50
+ }
51
+
52
+ struct Encoded {
53
+ let payload: Data
54
+ let uploads: [Data]
55
+ }
56
+
57
+ private(set) var generation = -1
58
+ private(set) var elements: [ElementState] = []
59
+ private var needsRepack = true
60
+
61
+ func reset(generation: Int) {
62
+ self.generation = generation
63
+ elements.removeAll()
64
+ needsRepack = true
65
+ }
66
+
67
+ /// The on-glass slot table is unknown (a send failed, the window moved): the next frame re-SETs everything.
68
+ func invalidate() {
69
+ needsRepack = true
70
+ }
71
+
72
+ // MARK: - Element → slots
73
+
74
+ /// Geometry parameters a TWEEN may interpolate, per type. Texture offsets and lengths never tween.
75
+ static func tweenableMask(_ type: UInt8) -> UInt16 {
76
+ switch ShapeType(rawValue: type) {
77
+ case .line: return 0x0F
78
+ case .rect, .rectFill, .arc, .pie: return 0x1F
79
+ case .circle, .circleFill: return 0x07
80
+ case .tri, .triFill, .bezier2: return 0x3F
81
+ case .quad, .quadFill, .bezier3: return 0xFF
82
+ case .image, .text, .textCached: return 0x03
83
+ case .none: return 0
84
+ }
85
+ }
86
+
87
+ static func easingCurve(_ easing: String) -> [UInt8] {
88
+ switch easing {
89
+ case "linear": return [0, 0, 255, 255]
90
+ case "ease-in": return [107, 0, 255, 255]
91
+ case "ease-out": return [0, 0, 148, 255]
92
+ case "ease-in-out": return [107, 0, 148, 255]
93
+ default: return [64, 26, 64, 255] // CSS "ease"
94
+ }
95
+ }
96
+
97
+ static func transitionFrames(ms: Int32) -> UInt8 {
98
+ guard ms > 0 else { return 0 }
99
+ let frames = Int((Double(ms) / framePeriodMs).rounded())
100
+ return UInt8(max(2, min(255, frames)))
101
+ }
102
+
103
+ private static func gray(_ element: SceneElement) -> UInt8 {
104
+ element.color < 0 ? 15 : UInt8(max(0, min(15, element.color)))
105
+ }
106
+
107
+ private static func stroke(_ element: SceneElement) -> UInt8 {
108
+ UInt8(max(1, min(255, element.border)))
109
+ }
110
+
111
+ private static func point(_ element: SceneElement, _ i: Int, ox: Int, oy: Int) -> (Int16, Int16) {
112
+ guard element.points.count >= 2 * i + 2 else { return (0, 0) }
113
+ return (Self.clampCoord(Int(element.points[2 * i]) + ox), Self.clampCoord(Int(element.points[2 * i + 1]) + oy))
114
+ }
115
+
116
+ private static func clampCoord(_ v: Int) -> Int16 {
117
+ Int16(max(-1024, min(1023, v)))
118
+ }
119
+
120
+ private static func circleGeometry(_ element: SceneElement, ox: Int, oy: Int) -> (Int16, Int16, Int16) {
121
+ let w = Int(element.w), h = Int(element.h)
122
+ let r = max(0, (min(w, h) - 1) / 2)
123
+ return (clampCoord(Int(element.x) + w / 2 + ox), clampCoord(Int(element.y) + h / 2 + oy), Int16(r))
124
+ }
125
+
126
+ /// Slots for one element, or nil when the texture cache cannot hold its content.
127
+ func slots(
128
+ for element: SceneElement, originX ox: Int, originY oy: Int,
129
+ cache: G2CfwTextureCache, rasterize: (SceneElement, Int, Int) -> Data?
130
+ ) -> [Slot]? {
131
+ let x = Self.clampCoord(Int(element.x) + ox)
132
+ let y = Self.clampCoord(Int(element.y) + oy)
133
+ let w = Int16(max(0, min(2047, Int(element.w))))
134
+ let h = Int16(max(0, min(2047, Int(element.h))))
135
+ let color = Self.gray(element)
136
+ let radius = Int16(max(0, min(2047, Int(element.radius))))
137
+ switch element.type {
138
+ case "rect":
139
+ var out: [Slot] = []
140
+ if element.fill {
141
+ out.append(Slot(.rectFill, color: color, [x, y, w, h, radius]))
142
+ if element.border > 0 {
143
+ out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
144
+ }
145
+ } else {
146
+ out.append(Slot(.rect, color: color, width: Self.stroke(element), [x, y, w, h, radius]))
147
+ }
148
+ return out
149
+ case "circle":
150
+ let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
151
+ if element.fill { return [Slot(.circleFill, color: color, [cx, cy, r])] }
152
+ return [Slot(.circle, color: color, width: Self.stroke(element), [cx, cy, r])]
153
+ case "arc", "pie":
154
+ let (cx, cy, r) = Self.circleGeometry(element, ox: ox, oy: oy)
155
+ let a0 = Self.clampCoord(Int(element.startDeg)), a1 = Self.clampCoord(Int(element.endDeg))
156
+ if element.type == "pie" { return [Slot(.pie, color: color, [cx, cy, r, a0, a1])] }
157
+ return [Slot(.arc, color: color, width: Self.stroke(element), [cx, cy, r, a0, a1])]
158
+ case "line":
159
+ let (x1, y1) = Self.point(element, 0, ox: ox, oy: oy)
160
+ let (x2, y2) = Self.point(element, 1, ox: ox, oy: oy)
161
+ return [Slot(.line, color: color, width: Self.stroke(element), [x1, y1, x2, y2])]
162
+ case "triangle", "quad":
163
+ let n = element.type == "triangle" ? 3 : 4
164
+ var params: [Int16] = []
165
+ for i in 0 ..< n {
166
+ let (px, py) = Self.point(element, i, ox: ox, oy: oy)
167
+ params.append(contentsOf: [px, py])
168
+ }
169
+ let filled: ShapeType = n == 3 ? .triFill : .quadFill
170
+ let stroked: ShapeType = n == 3 ? .tri : .quad
171
+ if element.fill { return [Slot(filled, color: color, params)] }
172
+ return [Slot(stroked, color: color, width: Self.stroke(element), params)]
173
+ case "bezier":
174
+ let n = element.points.count / 2
175
+ guard n == 3 || n == 4 else { return [] }
176
+ var params: [Int16] = []
177
+ for i in 0 ..< n {
178
+ let (px, py) = Self.point(element, i, ox: ox, oy: oy)
179
+ params.append(contentsOf: [px, py])
180
+ }
181
+ return [Slot(n == 3 ? .bezier2 : .bezier3, color: color, width: Self.stroke(element), params)]
182
+ case "text" where element.font == "builtin":
183
+ return builtinTextSlots(element, x: x, y: y, cache: cache)
184
+ case "text", "image":
185
+ return rasterSlots(element, x: x, y: y, cache: cache, rasterize: rasterize)
186
+ default:
187
+ return []
188
+ }
189
+ }
190
+
191
+ private func builtinTextSlots(_ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache) -> [Slot]? {
192
+ var out: [Slot] = []
193
+ if element.border > 0 {
194
+ out.append(Slot(.rect, color: 15, width: Self.stroke(element),
195
+ [x, y, Int16(min(2047, Int(element.w))), Int16(min(2047, Int(element.h))),
196
+ Int16(max(0, min(2047, Int(element.radius))))]))
197
+ }
198
+ let maxLines = max(1, Int(element.h) / Self.textLineHeight)
199
+ let lines = (element.text ?? "").split(separator: "\n", omittingEmptySubsequences: false).prefix(maxLines)
200
+ for (index, line) in lines.enumerated() {
201
+ let bytes = Self.textBytes(String(line))
202
+ if bytes.isEmpty { continue }
203
+ guard let offset = cache.ensureBytes(key: "t:" + String(line), bytes: bytes) else { return nil }
204
+ let lineY = Int(y) + index * Self.textLineHeight
205
+ out.append(Slot(.text, color: Self.textOptions,
206
+ [x, Self.clampCoord(lineY), Int16(bitPattern: UInt16(offset)), Int16(bytes.count)]))
207
+ }
208
+ return out
209
+ }
210
+
211
+ /// UTF-8 bytes of a line without firmware control bytes (1..31), cut at a codepoint boundary.
212
+ static func textBytes(_ line: String) -> [UInt8] {
213
+ let cleaned = line.unicodeScalars.filter { $0.value >= 32 }
214
+ var bytes: [UInt8] = []
215
+ for scalar in cleaned {
216
+ let encoded = Array(String(scalar).utf8)
217
+ if bytes.count + encoded.count > maxTextBytes { break }
218
+ bytes.append(contentsOf: encoded)
219
+ }
220
+ return bytes
221
+ }
222
+
223
+ private func rasterSlots(
224
+ _ element: SceneElement, x: Int16, y: Int16, cache: G2CfwTextureCache,
225
+ rasterize: (SceneElement, Int, Int) -> Data?
226
+ ) -> [Slot]? {
227
+ let w = Int(element.w), h = Int(element.h)
228
+ guard w > 0, h > 0, let gray = rasterize(element, w, h), gray.count == w * h else { return [] }
229
+ var out: [Slot] = []
230
+ var ty = 0
231
+ while ty < h {
232
+ let th = min(Self.maxTileSize, h - ty)
233
+ var tx = 0
234
+ while tx < w {
235
+ let tw = min(Self.maxTileSize, w - tx)
236
+ var tile = Data(capacity: tw * th)
237
+ for row in 0 ..< th {
238
+ let start = (ty + row) * w + tx
239
+ tile.append(gray.subdata(in: start ..< start + tw))
240
+ }
241
+ let key = "\(element.contentHash)@\(w)x\(h):\(tx),\(ty)"
242
+ guard let offset = cache.ensureImage(hash: key, gray: tile, width: tw, height: th) else { return nil }
243
+ out.append(Slot(.image, color: Self.imageOptions,
244
+ [Self.clampCoord(Int(x) + tx), Self.clampCoord(Int(y) + ty),
245
+ Int16(bitPattern: UInt16(offset))]))
246
+ tx += tw
247
+ }
248
+ ty += th
249
+ }
250
+ return out
251
+ }
252
+
253
+ // MARK: - Frame encoding
254
+
255
+ /// One mode-17 patch (COMMIT, plus CLEAR on a repack) and the mode-12 uploads
256
+ /// that must precede it. Nil when the frame does not fit the slot table or
257
+ /// the texture cache; the caller then falls back to the raster path.
258
+ func encode(
259
+ _ frame: SceneFrame, originX: Int, originY: Int, cache: G2CfwTextureCache,
260
+ rasterize: (SceneElement, Int, Int) -> Data?
261
+ ) -> Encoded? {
262
+ struct Desired {
263
+ let id: String
264
+ let contentHash: String
265
+ let slots: [Slot]
266
+ let frames: UInt8
267
+ let curve: [UInt8]
268
+ }
269
+ var desired: [Desired] = []
270
+ for element in frame.elements {
271
+ guard let slots = slots(for: element, originX: originX, originY: originY, cache: cache, rasterize: rasterize)
272
+ else { return nil }
273
+ desired.append(Desired(
274
+ id: element.id, contentHash: element.contentHash, slots: slots,
275
+ frames: Self.transitionFrames(ms: element.transitionMs),
276
+ curve: Self.easingCurve(element.easing)
277
+ ))
278
+ }
279
+ let total = desired.reduce(0) { $0 + $1.slots.count }
280
+ guard total <= Self.maxSlots else { return nil }
281
+
282
+ var previous: [String: ElementState] = [:]
283
+ for state in elements { previous[state.id] = state }
284
+
285
+ var repack = needsRepack || frame.replay
286
+ var assigned: [[Int]] = []
287
+ if !repack {
288
+ var used = Set<Int>()
289
+ for item in desired {
290
+ if let prev = previous[item.id], prev.slots.count == item.slots.count { used.formUnion(prev.indices) }
291
+ }
292
+ var next = (used.max() ?? -1) + 1
293
+ var lastIndex = -1
294
+ for item in desired {
295
+ let indices: [Int]
296
+ if let prev = previous[item.id], prev.slots.count == item.slots.count {
297
+ indices = prev.indices
298
+ } else {
299
+ indices = (0 ..< item.slots.count).map { next + $0 }
300
+ next += item.slots.count
301
+ }
302
+ if let first = indices.first, first <= lastIndex { repack = true; break }
303
+ lastIndex = indices.last ?? lastIndex
304
+ assigned.append(indices)
305
+ }
306
+ if next > Self.maxSlots { repack = true }
307
+ }
308
+ if repack {
309
+ assigned.removeAll()
310
+ var next = 0
311
+ for item in desired {
312
+ assigned.append((0 ..< item.slots.count).map { next + $0 })
313
+ next += item.slots.count
314
+ }
315
+ }
316
+
317
+ var ops = Data()
318
+ if !repack {
319
+ let kept = Set(desired.map(\.id))
320
+ for state in elements where !kept.contains(state.id) {
321
+ for index in state.indices { ops.append(contentsOf: [1, UInt8(index)]) }
322
+ }
323
+ }
324
+ var committed: [ElementState] = []
325
+ for (item, indices) in zip(desired, assigned) {
326
+ let prev = repack ? nil : previous[item.id]
327
+ for (k, slot) in item.slots.enumerated() {
328
+ let index = UInt8(indices[k])
329
+ if let prev, prev.slots.count == item.slots.count {
330
+ let old = prev.slots[k]
331
+ if old == slot { continue }
332
+ if item.frames >= 2, let tween = Self.tweenRecord(from: old, to: slot, index: index,
333
+ frames: item.frames, curve: item.curve) {
334
+ ops.append(tween)
335
+ continue
336
+ }
337
+ }
338
+ ops.append(Self.setRecord(slot, index: index))
339
+ }
340
+ committed.append(ElementState(id: item.id, contentHash: item.contentHash, slots: item.slots, indices: indices))
341
+ }
342
+
343
+ var payload = Data([17, repack ? 0x03 : 0x01, 0])
344
+ payload.append(ops)
345
+ elements = committed
346
+ needsRepack = false
347
+ return Encoded(payload: payload, uploads: cache.takeUploadPayloads())
348
+ }
349
+
350
+ /// `[0][slot][type][flags=visible][color][width][p0..p7 LE]`
351
+ static func setRecord(_ slot: Slot, index: UInt8) -> Data {
352
+ var out = Data([0, index, slot.type, 1, slot.color, slot.width])
353
+ for value in slot.params {
354
+ let bits = UInt16(bitPattern: value)
355
+ out.append(UInt8(bits & 0xFF))
356
+ out.append(UInt8(bits >> 8))
357
+ }
358
+ return out
359
+ }
360
+
361
+ /// `[5][slot][mask:u16][frames][curve x4][value:i16 per set bit]`, or nil when the
362
+ /// change is not a pure geometry/stroke change of the same shape type.
363
+ static func tweenRecord(from old: Slot, to new: Slot, index: UInt8, frames: UInt8, curve: [UInt8]) -> Data? {
364
+ guard old.type == new.type else { return nil }
365
+ let tweenable = tweenableMask(new.type)
366
+ var mask: UInt16 = 0
367
+ var values: [Int16] = []
368
+ for k in 0 ..< 8 where old.params[k] != new.params[k] {
369
+ guard tweenable & (1 << k) != 0 else { return nil }
370
+ mask |= 1 << k
371
+ values.append(new.params[k])
372
+ }
373
+ let isTexture = new.type >= ShapeType.image.rawValue
374
+ if old.color != new.color {
375
+ guard !isTexture else { return nil }
376
+ mask |= 0x100
377
+ values.append(Int16(new.color))
378
+ }
379
+ if old.width != new.width {
380
+ guard !isTexture else { return nil }
381
+ mask |= 0x200
382
+ values.append(Int16(new.width))
383
+ }
384
+ guard mask != 0 else { return nil }
385
+ var out = Data([5, index, UInt8(mask & 0xFF), UInt8(mask >> 8), frames])
386
+ out.append(contentsOf: curve.prefix(4))
387
+ for value in values {
388
+ let bits = UInt16(bitPattern: value)
389
+ out.append(UInt8(bits & 0xFF))
390
+ out.append(UInt8(bits >> 8))
391
+ }
392
+ return out
393
+ }
394
+ }
@@ -133,6 +133,16 @@ final class G2CfwTextureCache {
133
133
  return offset
134
134
  }
135
135
 
136
+ /// The cache offset of a raw byte string (builtin-font text for scene TEXT
137
+ /// slots), uploading it on first use.
138
+ func ensureBytes(key: String, bytes: [UInt8]) -> Int? {
139
+ if let offset = images[key] { return offset }
140
+ guard !bytes.isEmpty, let offset = allocate(bytes.count) else { return nil }
141
+ images[key] = offset
142
+ pendingUploads.append((offset: offset, data: Data(bytes)))
143
+ return offset
144
+ }
145
+
136
146
  /// Drain the pending mode-12 entries into `[12][offset16][len16][data]...`
137
147
  /// payloads, each at most `maxUploadPayloadBytes`. An entry larger than
138
148
  /// the budget gets a payload of its own (the BLE fragmenter splits it).
@@ -37,8 +37,8 @@ enum G2FirmwareCatalog {
37
37
  displayName: "Glassly CFW",
38
38
  version: "2.2.9.22 CFW",
39
39
  fileName: "g2-glassly-cfw-2.2.9.22.evenota.bin",
40
- sha256: "ceeede999529505ebc0a1369f57ab3c941273379a90b16669ccd39cc6bd99135",
41
- size: 4_515_846
40
+ sha256: "fe6051085507269c8007bc1245bfe8bd10d5c566540389ae976daaf753f536f9",
41
+ size: 4_527_476
42
42
  ),
43
43
  ]
44
44
 
@@ -5,7 +5,7 @@ import Foundation
5
5
  /// against the previous frame sent to this device.
6
6
  struct SceneElement: Sendable {
7
7
  let id: String
8
- let type: String // "text" | "rect" | "image"
8
+ let type: String // "text" | "rect" | "image" | "circle" | "arc" | "pie" | "line" | "triangle" | "quad" | "bezier"
9
9
  let x: Int32
10
10
  let y: Int32
11
11
  let w: Int32
@@ -20,11 +20,24 @@ struct SceneElement: Sendable {
20
20
  /// height (grid text); anything else is the default proportional face.
21
21
  let font: String
22
22
  let fontSize: Int32
23
+ /// Shapes: solid fill instead of an outline.
24
+ let fill: Bool
25
+ /// Gray level 0..15, or -1 for the default (brightest).
26
+ let color: Int32
27
+ /// Point shapes: x0, y0, x1, y1, … in canvas pixels.
28
+ let points: [Int32]
29
+ let startDeg: Int32
30
+ let endDeg: Int32
31
+ /// Transition duration in ms (0 = none) and easing name.
32
+ let transitionMs: Int32
33
+ let easing: String
23
34
 
24
35
  init(
25
36
  id: String, type: String, x: Int32, y: Int32, w: Int32, h: Int32, text: String?, data: String?,
26
37
  border: Int32, radius: Int32, change: String, contentHash: String,
27
- font: String = "default", fontSize: Int32 = 0
38
+ font: String = "default", fontSize: Int32 = 0,
39
+ fill: Bool = false, color: Int32 = -1, points: [Int32] = [], startDeg: Int32 = 0, endDeg: Int32 = 0,
40
+ transitionMs: Int32 = 0, easing: String = "ease"
28
41
  ) {
29
42
  self.id = id
30
43
  self.type = type
@@ -40,13 +53,31 @@ struct SceneElement: Sendable {
40
53
  self.contentHash = contentHash
41
54
  self.font = font
42
55
  self.fontSize = fontSize
56
+ self.fill = fill
57
+ self.color = color
58
+ self.points = points
59
+ self.startDeg = startDeg
60
+ self.endDeg = endDeg
61
+ self.transitionMs = transitionMs
62
+ self.easing = easing
43
63
  }
44
64
 
45
65
  func with(change: String) -> SceneElement {
46
66
  SceneElement(
47
67
  id: id, type: type, x: x, y: y, w: w, h: h, text: text, data: data,
48
68
  border: border, radius: radius, change: change, contentHash: contentHash,
49
- font: font, fontSize: fontSize
69
+ font: font, fontSize: fontSize, fill: fill, color: color, points: points,
70
+ startDeg: startDeg, endDeg: endDeg, transitionMs: transitionMs, easing: easing
71
+ )
72
+ }
73
+
74
+ /// The same element placed at the canvas origin (for standalone rasterization).
75
+ func atOrigin() -> SceneElement {
76
+ SceneElement(
77
+ id: id, type: type, x: 0, y: 0, w: w, h: h, text: text, data: data,
78
+ border: border, radius: radius, change: change, contentHash: contentHash,
79
+ font: font, fontSize: fontSize, fill: fill, color: color, points: points,
80
+ startDeg: startDeg, endDeg: endDeg, transitionMs: transitionMs, easing: easing
50
81
  )
51
82
  }
52
83
  }
@@ -0,0 +1,187 @@
1
+ @testable import BluetoothSDK
2
+ import Foundation
3
+ import XCTest
4
+
5
+ final class G2CfwSceneTests: XCTestCase {
6
+ private func element(
7
+ _ id: String, type: String, x: Int32 = 0, y: Int32 = 0, w: Int32 = 0, h: Int32 = 0,
8
+ text: String? = nil, border: Int32 = 0, radius: Int32 = 0, font: String = "default",
9
+ fill: Bool = false, color: Int32 = -1, points: [Int32] = [], startDeg: Int32 = 0, endDeg: Int32 = 0,
10
+ transitionMs: Int32 = 0, easing: String = "ease", hash: String = "h"
11
+ ) -> SceneElement {
12
+ SceneElement(
13
+ id: id, type: type, x: x, y: y, w: w, h: h, text: text, data: nil, border: border, radius: radius,
14
+ change: "created", contentHash: hash, font: font, fill: fill, color: color, points: points,
15
+ startDeg: startDeg, endDeg: endDeg, transitionMs: transitionMs, easing: easing
16
+ )
17
+ }
18
+
19
+ private func frame(_ elements: [SceneElement], replay: Bool = false) -> SceneFrame {
20
+ SceneFrame(appId: "app", epoch: 1, replay: replay, elements: elements, removed: [])
21
+ }
22
+
23
+ private func encode(_ scene: G2CfwScene, _ cache: G2CfwTextureCache, _ elements: [SceneElement],
24
+ replay: Bool = false) -> G2CfwScene.Encoded? {
25
+ scene.encode(frame(elements, replay: replay), originX: 32, originY: 96, cache: cache) { el, w, h in
26
+ Data(repeating: 0xFF, count: w * h)
27
+ }
28
+ }
29
+
30
+ /// Little-endian int16 bytes of each value, concatenated.
31
+ private func le(_ values: Int...) -> [UInt8] {
32
+ var out: [UInt8] = []
33
+ for v in values {
34
+ let bits = UInt16(bitPattern: Int16(v))
35
+ out.append(UInt8(bits & 0xFF))
36
+ out.append(UInt8(bits >> 8))
37
+ }
38
+ return out
39
+ }
40
+
41
+ func testShapesBecomeSetRecordsWithOffsetGeometry() throws {
42
+ let scene = G2CfwScene()
43
+ let cache = G2CfwTextureCache()
44
+ cache.reset(generation: 1)
45
+ scene.reset(generation: 1)
46
+ let encoded = try XCTUnwrap(encode(scene, cache, [
47
+ element("l", type: "line", border: 3, color: 12, points: [10, 20, 30, 40]),
48
+ element("c", type: "circle", x: 100, y: 100, w: 41, h: 41, fill: true),
49
+ element("a", type: "arc", x: 0, y: 0, w: 101, h: 101, border: 4, startDeg: -90, endDeg: 180),
50
+ element("r", type: "rect", x: 1, y: 2, w: 30, h: 20, border: 2, radius: 6, fill: true),
51
+ ]))
52
+ XCTAssertTrue(encoded.uploads.isEmpty)
53
+ let bytes = [UInt8](encoded.payload)
54
+ // header: mode 17, COMMIT|CLEAR on the first (repack) frame, black background
55
+ XCTAssertEqual(Array(bytes[0 ..< 3]), [17, 0x03, 0])
56
+ // line at slot 0: type 1, visible, color 12, width 3, points shifted by the origin
57
+ XCTAssertEqual(Array(bytes[3 ..< 9]), [0, 0, 1, 1, 12, 3])
58
+ XCTAssertEqual(Array(bytes[9 ..< 17]), le(42, 116, 62, 136))
59
+ // filled circle at slot 1: center + radius from the box
60
+ let circle = 3 + 22
61
+ XCTAssertEqual(Array(bytes[circle ..< circle + 6]), [0, 1, 5, 1, 15, 0])
62
+ XCTAssertEqual(Array(bytes[circle + 6 ..< circle + 12]), le(152, 216, 20))
63
+ // arc at slot 2 keeps its angles
64
+ let arc = circle + 22
65
+ XCTAssertEqual(Array(bytes[arc ..< arc + 6]), [0, 2, 12, 1, 15, 4])
66
+ XCTAssertEqual(Array(bytes[arc + 12 ..< arc + 16]), le(-90, 180))
67
+ // filled rect with a border is two slots: fill then stroke
68
+ let rect = arc + 22
69
+ XCTAssertEqual(Array(bytes[rect ..< rect + 6]), [0, 3, 3, 1, 15, 0])
70
+ XCTAssertEqual(Array(bytes[rect + 22 ..< rect + 28]), [0, 4, 2, 1, 15, 2])
71
+ XCTAssertEqual(bytes.count, rect + 44)
72
+ XCTAssertEqual(scene.elements.map(\.indices), [[0], [1], [2], [3, 4]])
73
+ }
74
+
75
+ func testMovedElementWithTransitionBecomesTween() throws {
76
+ let scene = G2CfwScene()
77
+ let cache = G2CfwTextureCache()
78
+ cache.reset(generation: 1)
79
+ scene.reset(generation: 1)
80
+ _ = try XCTUnwrap(encode(scene, cache, [
81
+ element("ball", type: "circle", x: 0, y: 0, w: 21, h: 21, fill: true),
82
+ ]))
83
+ let next = try XCTUnwrap(encode(scene, cache, [
84
+ element("ball", type: "circle", x: 200, y: 0, w: 21, h: 21, fill: true, transitionMs: 330, easing: "linear"),
85
+ ]))
86
+ let bytes = [UInt8](next.payload)
87
+ XCTAssertEqual(Array(bytes[0 ..< 3]), [17, 0x01, 0])
88
+ // TWEEN slot 0, mask p0 only, 10 frames, linear curve, target x
89
+ XCTAssertEqual(Array(bytes[3 ..< 12]), [5, 0, 0x01, 0x00, 10, 0, 0, 255, 255])
90
+ XCTAssertEqual(Array(bytes[12 ..< 14]), le(242))
91
+ XCTAssertEqual(bytes.count, 14)
92
+ }
93
+
94
+ func testGeometryChangeWithoutTransitionAndTypeChangeReSet() throws {
95
+ let scene = G2CfwScene()
96
+ let cache = G2CfwTextureCache()
97
+ cache.reset(generation: 1)
98
+ scene.reset(generation: 1)
99
+ _ = try XCTUnwrap(encode(scene, cache, [element("s", type: "circle", w: 11, h: 11)]))
100
+ let moved = try XCTUnwrap(encode(scene, cache, [element("s", type: "circle", x: 5, w: 11, h: 11)]))
101
+ XCTAssertEqual([UInt8](moved.payload)[3 ..< 5], [0, 0])
102
+ let retyped = try XCTUnwrap(encode(scene, cache, [
103
+ element("s", type: "circle", x: 5, w: 11, h: 11, fill: true, transitionMs: 500),
104
+ ]))
105
+ XCTAssertEqual([UInt8](retyped.payload)[3 ..< 6], [0, 0, 5])
106
+ }
107
+
108
+ func testRemovalDeletesSlotsAndReorderRepacks() throws {
109
+ let scene = G2CfwScene()
110
+ let cache = G2CfwTextureCache()
111
+ cache.reset(generation: 1)
112
+ scene.reset(generation: 1)
113
+ _ = try XCTUnwrap(encode(scene, cache, [
114
+ element("a", type: "circle", w: 11, h: 11),
115
+ element("b", type: "circle", w: 11, h: 11),
116
+ ]))
117
+ let dropped = try XCTUnwrap(encode(scene, cache, [element("b", type: "circle", w: 11, h: 11)]))
118
+ XCTAssertEqual([UInt8](dropped.payload), [17, 0x01, 0, 1, 0])
119
+ XCTAssertEqual(scene.elements.map(\.indices), [[1]])
120
+ // "a" returns in front of "b": slot order would break paint order, so the frame repacks
121
+ let reordered = try XCTUnwrap(encode(scene, cache, [
122
+ element("a", type: "circle", w: 11, h: 11),
123
+ element("b", type: "circle", w: 11, h: 11),
124
+ ]))
125
+ XCTAssertEqual([UInt8](reordered.payload)[1], 0x03)
126
+ XCTAssertEqual(scene.elements.map(\.indices), [[0], [1]])
127
+ }
128
+
129
+ func testBuiltinTextAndRasterTilesUseTheTextureCache() throws {
130
+ let scene = G2CfwScene()
131
+ let cache = G2CfwTextureCache()
132
+ cache.reset(generation: 1)
133
+ scene.reset(generation: 1)
134
+ let encoded = try XCTUnwrap(encode(scene, cache, [
135
+ element("t", type: "text", x: 10, y: 10, w: 200, h: 60, text: "hi\nthere", font: "builtin"),
136
+ element("i", type: "image", x: 0, y: 0, w: 300, h: 10, hash: "img"),
137
+ ]))
138
+ XCTAssertEqual(encoded.uploads.count, 1)
139
+ // "hi" then "there" in the cache right after the zero guard
140
+ let expectedUpload: [UInt8] = [12, 2, 0, 2, 0, 0x68, 0x69, 4, 0, 5, 0]
141
+ XCTAssertEqual(Array(encoded.uploads[0].prefix(11)), expectedUpload)
142
+ let bytes = [UInt8](encoded.payload)
143
+ // two TEXT slots (27 px line pitch) + two IMAGE tiles (255 + 45 px)
144
+ XCTAssertEqual(scene.elements.map(\.indices), [[0, 1], [2, 3]])
145
+ XCTAssertEqual(Array(bytes[3 ..< 6]), [0, 0, 15])
146
+ XCTAssertEqual(Array(bytes[9 ..< 17]), le(42, 106, 2, 2))
147
+ let second = 3 + 22
148
+ XCTAssertEqual(Array(bytes[second + 6 ..< second + 14]), le(42, 133, 4, 5))
149
+ let tile2 = 3 + 3 * 22
150
+ XCTAssertEqual(Array(bytes[tile2 ..< tile2 + 3]), [0, 3, 14])
151
+ XCTAssertEqual(Array(bytes[tile2 + 6 ..< tile2 + 10]), le(32 + 255, 96))
152
+ // identical content re-renders without new uploads or ops
153
+ let again = try XCTUnwrap(encode(scene, cache, [
154
+ element("t", type: "text", x: 10, y: 10, w: 200, h: 60, text: "hi\nthere", font: "builtin"),
155
+ element("i", type: "image", x: 0, y: 0, w: 300, h: 10, hash: "img"),
156
+ ]))
157
+ XCTAssertTrue(again.uploads.isEmpty)
158
+ XCTAssertEqual([UInt8](again.payload), [17, 0x01, 0])
159
+ }
160
+
161
+ func testTooManySlotsOrFullCacheReturnsNil() {
162
+ let scene = G2CfwScene()
163
+ let cache = G2CfwTextureCache()
164
+ cache.reset(generation: 1)
165
+ scene.reset(generation: 1)
166
+ let many = (0 ..< 129).map { element("s\($0)", type: "circle", w: 11, h: 11) }
167
+ XCTAssertNil(encode(scene, cache, many))
168
+ let huge = element("big", type: "image", w: 2000, h: 2000, hash: "big")
169
+ let full = scene.encode(frame([huge]), originX: 0, originY: 0, cache: cache) { _, w, h in
170
+ var noise = Data(count: w * h)
171
+ for i in 0 ..< noise.count { noise[i] = UInt8(truncatingIfNeeded: i * 37) }
172
+ return noise
173
+ }
174
+ XCTAssertNil(full)
175
+ }
176
+
177
+ func testTextBytesStripControlsAndCutAtCodepoints() {
178
+ XCTAssertEqual(G2CfwScene.textBytes("a\u{01}b"), Array("ab".utf8))
179
+ let long = String(repeating: "é", count: 40) // 80 UTF-8 bytes
180
+ XCTAssertEqual(G2CfwScene.textBytes(long).count, 64)
181
+ XCTAssertEqual(G2CfwScene.transitionFrames(ms: 0), 0)
182
+ XCTAssertEqual(G2CfwScene.transitionFrames(ms: 10), 2)
183
+ XCTAssertEqual(G2CfwScene.transitionFrames(ms: 990), 30)
184
+ XCTAssertEqual(G2CfwScene.transitionFrames(ms: 60000), 255)
185
+ XCTAssertEqual(G2CfwScene.easingCurve("ease-in-out"), [107, 0, 148, 255])
186
+ }
187
+ }