json2commands.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. # Copyright 2024 Google LLC
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. '''
  15. JSON2COMMANDS creates Pebble Draw Commands (the Python Objects, _not_ a serialized .pdc) from a JSON file.
  16. Currently only the PathCommand is supported.
  17. The JSON file can contain multiple frames (i.e. PDC Sequence).
  18. Each frame is composed of 'fillGroups'.
  19. A fillGroup may be: An individual filled polygon (a.k.a. a fill), or _all_ unfilled polylines (a.k.a. all open paths).
  20. Each fillGroup is parsed separately and a list of Pebble Draw Commands that describe it is created.
  21. The created list should have the length of the lowest number of commands possible in order to draw that fillGroup.
  22. Currently, there is no support for a JSON to contain the viewbox size or fill colors.
  23. The viewbox size is currently passed in as a parameter.
  24. The fill color is currently defaulted to solid white.
  25. '''
  26. import os
  27. import argparse
  28. from . import pebble_commands
  29. import json
  30. from . import graph
  31. from itertools import groupby
  32. INVISIBLE_POINT_THRESHOLD = 500
  33. DISPLAY_DIM_X = 144
  34. DISPLAY_DIM_Y = 168
  35. OPEN_PATH_TAG = "_"
  36. def parse_color(color_opacity, truncate):
  37. if color_opacity is None:
  38. return 0
  39. r = int(round(255 * color_opacity[0]))
  40. g = int(round(255 * color_opacity[1]))
  41. b = int(round(255 * color_opacity[2]))
  42. a = int(round(255 * color_opacity[3]))
  43. return pebble_commands.convert_color(r, g, b, a, truncate)
  44. def parse_json_line_data(json_line_data, viewbox_size=(DISPLAY_DIM_X, DISPLAY_DIM_Y)):
  45. # A list of one-way vectors, but intended to store their negatives at all times.
  46. bidirectional_lines = []
  47. for line_data in json_line_data:
  48. # Skip invisible lines
  49. if abs(line_data['startPoint'][0]) > INVISIBLE_POINT_THRESHOLD or \
  50. abs(line_data['startPoint'][1]) > INVISIBLE_POINT_THRESHOLD or \
  51. abs(line_data['endPoint'][0]) > INVISIBLE_POINT_THRESHOLD or \
  52. abs(line_data['endPoint'][1]) > INVISIBLE_POINT_THRESHOLD:
  53. continue
  54. # Center the viewbox of all lines (by moving the lines' absolute
  55. # coordinates relative to the screen)
  56. dx = -(DISPLAY_DIM_X - viewbox_size[0]) / 2
  57. dy = -(DISPLAY_DIM_Y - viewbox_size[1]) / 2
  58. start_point = (line_data["startPoint"][0] + dx, line_data["startPoint"][1] + dy)
  59. end_point = (line_data["endPoint"][0] + dx, line_data["endPoint"][1] + dy)
  60. # Since lines are represented and stored as one-way vectors, but may be
  61. # drawn in either direction, all operations must be done on their reverse
  62. line = (start_point, end_point)
  63. reverse_line = (end_point, start_point)
  64. # Skip duplicate lines
  65. if line in bidirectional_lines:
  66. continue
  67. bidirectional_lines.append(line)
  68. bidirectional_lines.append(reverse_line)
  69. return bidirectional_lines
  70. def determine_longest_path(bidirectional_lines):
  71. '''
  72. Returns the longest path in 'bidirectional_lines', and removes all its segments from 'bidirectional_lines'
  73. If 'bidirectional_lines' contains more than one possible longest path, only one will be returned.
  74. '''
  75. # Construct graph out of bidirectional_lines
  76. g = graph.Graph({})
  77. for line in bidirectional_lines:
  78. g.add_edge(line)
  79. # Find longest path
  80. longest_path_length = 0
  81. longest_path = []
  82. vertices = g.get_vertices()
  83. for i in range(len(vertices)):
  84. start_vertex = vertices[i]
  85. for j in range(i, len(vertices)):
  86. end_vertex = vertices[j]
  87. paths = g.find_all_paths(start_vertex, end_vertex)
  88. for path in paths:
  89. if (len(path) - 1) > longest_path_length:
  90. longest_path = path
  91. longest_path_length = len(path) - 1
  92. # Edge case - Line is a point
  93. if len(longest_path) == 1:
  94. longest_path = [longest_path, longest_path]
  95. # Remove longest_path's line segments from bidirectional_lines
  96. # Since bidirectional_lines is a list of one-way vectors but represents
  97. # bidirectional lines, a line segment and its reverse must be removed to
  98. # keep its integrity
  99. for k in range(len(longest_path) - 1):
  100. path_line = (longest_path[k], longest_path[k + 1])
  101. reverse_path_line = (path_line[1], path_line[0])
  102. bidirectional_lines.remove(path_line)
  103. bidirectional_lines.remove(reverse_path_line)
  104. return longest_path
  105. def process_unique_group_of_lines(unique_group_data, translate, viewbox_size, path_open, stroke_width, stroke_color, fill_color, precise, raise_error):
  106. '''
  107. Creates a list of commands that draw out a unique group of lines.
  108. A unique group of lines is defined as having a unique stroke width, stroke color, and fill.
  109. Note that this does _not_ guarantee the group may be described by a single Pebble Draw Command.
  110. '''
  111. unique_group_commands = []
  112. bidirectional_lines = parse_json_line_data(unique_group_data, viewbox_size)
  113. if not bidirectional_lines:
  114. return unique_group_commands
  115. while bidirectional_lines:
  116. longest_path = determine_longest_path(bidirectional_lines)
  117. try:
  118. c = pebble_commands.PathCommand(longest_path,
  119. path_open,
  120. translate,
  121. stroke_width,
  122. stroke_color,
  123. fill_color,
  124. precise,
  125. raise_error)
  126. if c is not None:
  127. unique_group_commands.append(c)
  128. except pebble_commands.InvalidPointException:
  129. raise
  130. return unique_group_commands
  131. def process_fill(fillGroup_data, translate, viewbox_size, path_open, precise, raise_error, truncate_color):
  132. fill_command = []
  133. error = False
  134. # A fill is implicitly a unique group of lines - all line segments must have the same stroke width, stroke color
  135. # Get line style from first line segment
  136. stroke_width = fillGroup_data[0]['thickness']
  137. stroke_color = parse_color(fillGroup_data[0]['color'], truncate_color)
  138. # Fill color should be solid white until it can be inserted in the JSON
  139. fill_color = parse_color([1, 1, 1, 1], truncate_color)
  140. if stroke_color == 0:
  141. stroke_width = 0
  142. elif stroke_width == 0:
  143. stroke_color = 0
  144. try:
  145. unique_group_commands = process_unique_group_of_lines(
  146. fillGroup_data,
  147. translate,
  148. viewbox_size,
  149. path_open,
  150. stroke_width,
  151. stroke_color,
  152. fill_color,
  153. precise,
  154. raise_error)
  155. if unique_group_commands:
  156. fill_command += unique_group_commands
  157. except pebble_commands.InvalidPointException:
  158. error = True
  159. return fill_command, error
  160. def process_open_paths(fillGroup_data, translate, viewbox_size, path_open, precise, raise_error, truncate_color):
  161. open_paths_commands = []
  162. error = False
  163. fill_color = parse_color([0, 0, 0, 0], truncate_color) # No fill color
  164. # These open paths are part of the same fillGroup, but may have varied stroke width
  165. fillGroup_data = sorted(fillGroup_data, key=lambda a: a['thickness'])
  166. for stroke_width, unique_width_group in groupby(fillGroup_data, lambda c: c['thickness']):
  167. unique_width_data = list(unique_width_group)
  168. # These open paths have the same width, but may have varied color
  169. unique_width_data = sorted(unique_width_data, key=lambda d: d['color'])
  170. for stroke_color_raw, unique_width_and_color_group in groupby(unique_width_data, lambda e: e['color']):
  171. # These are a unique group of lines
  172. unique_width_and_color_data = list(unique_width_and_color_group)
  173. stroke_color = parse_color(stroke_color_raw, truncate_color)
  174. if stroke_color == 0:
  175. stroke_width = 0
  176. elif stroke_width == 0:
  177. stroke_color = 0
  178. try:
  179. unique_group_commands = process_unique_group_of_lines(
  180. unique_width_and_color_data,
  181. translate,
  182. viewbox_size,
  183. path_open,
  184. stroke_width,
  185. stroke_color,
  186. fill_color,
  187. precise,
  188. raise_error)
  189. if unique_group_commands:
  190. open_paths_commands += unique_group_commands
  191. except pebble_commands.InvalidPointException:
  192. error = True
  193. return open_paths_commands, error
  194. def get_commands(translate, viewbox_size, frame_data, precise=False, raise_error=False, truncate_color=True):
  195. commands = []
  196. errors = []
  197. fillGroups_data = frame_data['lineData']
  198. # The 'fillGroup' property describes the type of group: A unique letter
  199. # (e.g. "A", "B", "C" etc.) for a unique fill, and a special identifier
  200. # for ALL open paths (non-fills)
  201. only_fills = list([d for d in fillGroups_data if d["fillGroup"] != OPEN_PATH_TAG])
  202. only_fills = sorted(only_fills, key=lambda f: f["fillGroup"]) # Don't assume data is sorted
  203. only_open_paths = list([d for d in fillGroups_data if d["fillGroup"] == OPEN_PATH_TAG])
  204. # Fills must be drawn before open paths, so place them first
  205. ordered_fill_groups = only_fills + only_open_paths
  206. # Process fillGroups
  207. for path_type, fillGroup in groupby(ordered_fill_groups, lambda b: b['fillGroup']):
  208. fillGroup_data = list(fillGroup)
  209. path_open = path_type == '_'
  210. if not path_open:
  211. # Filled fillGroup
  212. fillGroup_commands, error = process_fill(
  213. fillGroup_data,
  214. translate,
  215. viewbox_size,
  216. path_open,
  217. precise,
  218. raise_error,
  219. truncate_color)
  220. else:
  221. # Open path fillGroup
  222. fillGroup_commands, error = process_open_paths(
  223. fillGroup_data,
  224. translate,
  225. viewbox_size,
  226. path_open,
  227. precise,
  228. raise_error,
  229. truncate_color)
  230. if error:
  231. errors += str(path_type)
  232. elif fillGroup_commands:
  233. commands += fillGroup_commands
  234. if not commands:
  235. # Insert one 'invisible' command so the frame is valid
  236. c = pebble_commands.PathCommand([((0.0), (0.0)), ((0.0), (0.0))],
  237. True,
  238. translate,
  239. 0,
  240. 0,
  241. 0)
  242. commands.append(c)
  243. return commands, errors
  244. def parse_json_sequence(filename, viewbox_size, precise=False, raise_error=False):
  245. frames = []
  246. errors = []
  247. translate = (0, 0)
  248. with open(filename) as json_file:
  249. try:
  250. data = json.load(json_file)
  251. except ValueError:
  252. print('Invalid JSON format')
  253. return frames, 0, 0
  254. frames_data = data['lineData']
  255. frame_duration = int(data['compData']['frameDuration'] * 1000)
  256. for idx, frame_data in enumerate(frames_data):
  257. cmd_list, frame_errors = get_commands(
  258. translate,
  259. viewbox_size,
  260. frame_data,
  261. precise,
  262. raise_error)
  263. if frame_errors:
  264. errors.append((idx, frame_errors))
  265. elif cmd_list is not None:
  266. frames.append(cmd_list)
  267. return frames, errors, frame_duration
  268. if __name__ == '__main__':
  269. parser = argparse.ArgumentParser()
  270. parser.add_argument('path', type=str, help="Path to json file")
  271. args = parser.parse_args()
  272. path = os.path.abspath(args.path)
  273. parse_json_sequence(path)