pebble/tools/generate_pdcs/json2commands.py

338 lines
13 KiB
Python
Raw Permalink Normal View History

# Copyright 2024 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
'''
JSON2COMMANDS creates Pebble Draw Commands (the Python Objects, _not_ a serialized .pdc) from a JSON file.
Currently only the PathCommand is supported.
The JSON file can contain multiple frames (i.e. PDC Sequence).
Each frame is composed of 'fillGroups'.
A fillGroup may be: An individual filled polygon (a.k.a. a fill), or _all_ unfilled polylines (a.k.a. all open paths).
Each fillGroup is parsed separately and a list of Pebble Draw Commands that describe it is created.
The created list should have the length of the lowest number of commands possible in order to draw that fillGroup.
Currently, there is no support for a JSON to contain the viewbox size or fill colors.
The viewbox size is currently passed in as a parameter.
The fill color is currently defaulted to solid white.
'''
import os
import argparse
import pebble_commands
import json
import graph
from itertools import groupby
INVISIBLE_POINT_THRESHOLD = 500
DISPLAY_DIM_X = 144
DISPLAY_DIM_Y = 168
OPEN_PATH_TAG = "_"
def parse_color(color_opacity, truncate):
if color_opacity is None:
return 0
r = int(round(255 * color_opacity[0]))
g = int(round(255 * color_opacity[1]))
b = int(round(255 * color_opacity[2]))
a = int(round(255 * color_opacity[3]))
return pebble_commands.convert_color(r, g, b, a, truncate)
def parse_json_line_data(json_line_data, viewbox_size=(DISPLAY_DIM_X, DISPLAY_DIM_Y)):
# A list of one-way vectors, but intended to store their negatives at all times.
bidirectional_lines = []
for line_data in json_line_data:
# Skip invisible lines
if abs(line_data['startPoint'][0]) > INVISIBLE_POINT_THRESHOLD or \
abs(line_data['startPoint'][1]) > INVISIBLE_POINT_THRESHOLD or \
abs(line_data['endPoint'][0]) > INVISIBLE_POINT_THRESHOLD or \
abs(line_data['endPoint'][1]) > INVISIBLE_POINT_THRESHOLD:
continue
# Center the viewbox of all lines (by moving the lines' absolute
# coordinates relative to the screen)
dx = -(DISPLAY_DIM_X - viewbox_size[0]) / 2
dy = -(DISPLAY_DIM_Y - viewbox_size[1]) / 2
start_point = (line_data["startPoint"][0] + dx, line_data["startPoint"][1] + dy)
end_point = (line_data["endPoint"][0] + dx, line_data["endPoint"][1] + dy)
# Since lines are represented and stored as one-way vectors, but may be
# drawn in either direction, all operations must be done on their reverse
line = (start_point, end_point)
reverse_line = (end_point, start_point)
# Skip duplicate lines
if line in bidirectional_lines:
continue
bidirectional_lines.append(line)
bidirectional_lines.append(reverse_line)
return bidirectional_lines
def determine_longest_path(bidirectional_lines):
'''
Returns the longest path in 'bidirectional_lines', and removes all its segments from 'bidirectional_lines'
If 'bidirectional_lines' contains more than one possible longest path, only one will be returned.
'''
# Construct graph out of bidirectional_lines
g = graph.Graph({})
for line in bidirectional_lines:
g.add_edge(line)
# Find longest path
longest_path_length = 0
longest_path = []
vertices = g.get_vertices()
for i in range(len(vertices)):
start_vertex = vertices[i]
for j in range(i, len(vertices)):
end_vertex = vertices[j]
paths = g.find_all_paths(start_vertex, end_vertex)
for path in paths:
if (len(path) - 1) > longest_path_length:
longest_path = path
longest_path_length = len(path) - 1
# Edge case - Line is a point
if len(longest_path) == 1:
longest_path = [longest_path, longest_path]
# Remove longest_path's line segments from bidirectional_lines
# Since bidirectional_lines is a list of one-way vectors but represents
# bidirectional lines, a line segment and its reverse must be removed to
# keep its integrity
for k in range(len(longest_path) - 1):
path_line = (longest_path[k], longest_path[k + 1])
reverse_path_line = (path_line[1], path_line[0])
bidirectional_lines.remove(path_line)
bidirectional_lines.remove(reverse_path_line)
return longest_path
def process_unique_group_of_lines(unique_group_data, translate, viewbox_size, path_open, stroke_width, stroke_color, fill_color, precise, raise_error):
'''
Creates a list of commands that draw out a unique group of lines.
A unique group of lines is defined as having a unique stroke width, stroke color, and fill.
Note that this does _not_ guarantee the group may be described by a single Pebble Draw Command.
'''
unique_group_commands = []
bidirectional_lines = parse_json_line_data(unique_group_data, viewbox_size)
if not bidirectional_lines:
return unique_group_commands
while bidirectional_lines:
longest_path = determine_longest_path(bidirectional_lines)
try:
c = pebble_commands.PathCommand(longest_path,
path_open,
translate,
stroke_width,
stroke_color,
fill_color,
precise,
raise_error)
if c is not None:
unique_group_commands.append(c)
except pebble_commands.InvalidPointException:
raise
return unique_group_commands
def process_fill(fillGroup_data, translate, viewbox_size, path_open, precise, raise_error, truncate_color):
fill_command = []
error = False
# A fill is implicitly a unique group of lines - all line segments must have the same stroke width, stroke color
# Get line style from first line segment
stroke_width = fillGroup_data[0]['thickness']
stroke_color = parse_color(fillGroup_data[0]['color'], truncate_color)
# Fill color should be solid white until it can be inserted in the JSON
fill_color = parse_color([1, 1, 1, 1], truncate_color)
if stroke_color == 0:
stroke_width = 0
elif stroke_width == 0:
stroke_color = 0
try:
unique_group_commands = process_unique_group_of_lines(
fillGroup_data,
translate,
viewbox_size,
path_open,
stroke_width,
stroke_color,
fill_color,
precise,
raise_error)
if unique_group_commands:
fill_command += unique_group_commands
except pebble_commands.InvalidPointException:
error = True
return fill_command, error
def process_open_paths(fillGroup_data, translate, viewbox_size, path_open, precise, raise_error, truncate_color):
open_paths_commands = []
error = False
fill_color = parse_color([0, 0, 0, 0], truncate_color) # No fill color
# These open paths are part of the same fillGroup, but may have varied stroke width
fillGroup_data = sorted(fillGroup_data, key=lambda a: a['thickness'])
for stroke_width, unique_width_group in groupby(fillGroup_data, lambda c: c['thickness']):
unique_width_data = list(unique_width_group)
# These open paths have the same width, but may have varied color
unique_width_data = sorted(unique_width_data, key=lambda d: d['color'])
for stroke_color_raw, unique_width_and_color_group in groupby(unique_width_data, lambda e: e['color']):
# These are a unique group of lines
unique_width_and_color_data = list(unique_width_and_color_group)
stroke_color = parse_color(stroke_color_raw, truncate_color)
if stroke_color == 0:
stroke_width = 0
elif stroke_width == 0:
stroke_color = 0
try:
unique_group_commands = process_unique_group_of_lines(
unique_width_and_color_data,
translate,
viewbox_size,
path_open,
stroke_width,
stroke_color,
fill_color,
precise,
raise_error)
if unique_group_commands:
open_paths_commands += unique_group_commands
except pebble_commands.InvalidPointException:
error = True
return open_paths_commands, error
def get_commands(translate, viewbox_size, frame_data, precise=False, raise_error=False, truncate_color=True):
commands = []
errors = []
fillGroups_data = frame_data['lineData']
# The 'fillGroup' property describes the type of group: A unique letter
# (e.g. "A", "B", "C" etc.) for a unique fill, and a special identifier
# for ALL open paths (non-fills)
only_fills = list([d for d in fillGroups_data if d["fillGroup"] != OPEN_PATH_TAG])
only_fills = sorted(only_fills, key=lambda f: f["fillGroup"]) # Don't assume data is sorted
only_open_paths = list([d for d in fillGroups_data if d["fillGroup"] == OPEN_PATH_TAG])
# Fills must be drawn before open paths, so place them first
ordered_fill_groups = only_fills + only_open_paths
# Process fillGroups
for path_type, fillGroup in groupby(ordered_fill_groups, lambda b: b['fillGroup']):
fillGroup_data = list(fillGroup)
path_open = path_type == '_'
if not path_open:
# Filled fillGroup
fillGroup_commands, error = process_fill(
fillGroup_data,
translate,
viewbox_size,
path_open,
precise,
raise_error,
truncate_color)
else:
# Open path fillGroup
fillGroup_commands, error = process_open_paths(
fillGroup_data,
translate,
viewbox_size,
path_open,
precise,
raise_error,
truncate_color)
if error:
errors += str(path_type)
elif fillGroup_commands:
commands += fillGroup_commands
if not commands:
# Insert one 'invisible' command so the frame is valid
c = pebble_commands.PathCommand([((0.0), (0.0)), ((0.0), (0.0))],
True,
translate,
0,
0,
0)
commands.append(c)
return commands, errors
def parse_json_sequence(filename, viewbox_size, precise=False, raise_error=False):
frames = []
errors = []
translate = (0, 0)
with open(filename) as json_file:
try:
data = json.load(json_file)
except ValueError:
print('Invalid JSON format')
return frames, 0, 0
frames_data = data['lineData']
frame_duration = int(data['compData']['frameDuration'] * 1000)
for idx, frame_data in enumerate(frames_data):
cmd_list, frame_errors = get_commands(
translate,
viewbox_size,
frame_data,
precise,
raise_error)
if frame_errors:
errors.append((idx, frame_errors))
elif cmd_list is not None:
frames.append(cmd_list)
return frames, errors, frame_duration
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('path', type=str, help="Path to json file")
args = parser.parse_args()
path = os.path.abspath(args.path)
parse_json_sequence(path)