"""Собирает lottie-морфы иконок прямо из шрифта Material Symbols. Контуры глифов берутся из MaterialSymbolsOutlined.ttf (инстанс по умолчанию — FILL 0, GRAD 0, opsz 24, wght 400, то есть ровно то, что рисует Icon в приложении), разбиваются на равное число безье-сегментов и попарно сопоставляются, чтобы lottie мог интерполировать один глиф в другой. Спекам с fill=1 контуры считаются по FILL=1 — для кнопок, которые рисуют Icon(..., fill: 1). SPECS — морфы композера (ComposerMorphIcon), проигрываются вперёд. SLASH_SPECS — переключатели «обычная/перечёркнутая» (LottieSlashIcon): оба глифа лежат статикой, а перечёркивание рисуется бегущей по диагонали маской, поэтому одного ассета хватает на оба направления. python3 tool/make_morph_icons.py Пересобирать нужно после обновления material_symbols_icons. """ import json import math import os import struct class Font: def __init__(self, path): self.data = open(path, 'rb').read() self.tables = {} num_tables = struct.unpack('>H', self.data[4:6])[0] for i in range(num_tables): off = 12 + i * 16 tag = self.data[off:off + 4].decode('latin1') t_off, t_len = struct.unpack('>II', self.data[off + 8:off + 16]) self.tables[tag] = (t_off, t_len) head_off = self.tables['head'][0] self.units_per_em = struct.unpack( '>H', self.data[head_off + 18:head_off + 20])[0] self.index_to_loc = struct.unpack( '>h', self.data[head_off + 50:head_off + 52])[0] maxp_off = self.tables['maxp'][0] self.num_glyphs = struct.unpack( '>H', self.data[maxp_off + 4:maxp_off + 6])[0] self._read_loca() self._read_cmap() self._read_fvar() self._read_gvar() def _read_loca(self): off, _ = self.tables['loca'] n = self.num_glyphs + 1 if self.index_to_loc == 0: raw = struct.unpack('>%dH' % n, self.data[off:off + 2 * n]) self.loca = [v * 2 for v in raw] else: self.loca = list(struct.unpack('>%dI' % n, self.data[off:off + 4 * n])) def _read_cmap(self): off, _ = self.tables['cmap'] n = struct.unpack('>H', self.data[off + 2:off + 4])[0] best = None for i in range(n): rec = off + 4 + i * 8 pid, eid, sub = struct.unpack('>HHI', self.data[rec:rec + 8]) fmt = struct.unpack('>H', self.data[off + sub:off + sub + 2])[0] if fmt in (4, 12): if best is None or fmt == 12: best = (fmt, off + sub) fmt, sub = best self.cmap = {} if fmt == 4: seg_x2 = struct.unpack('>H', self.data[sub + 6:sub + 8])[0] seg = seg_x2 // 2 base = sub + 14 ends = struct.unpack('>%dH' % seg, self.data[base:base + seg_x2]) base += seg_x2 + 2 starts = struct.unpack('>%dH' % seg, self.data[base:base + seg_x2]) base += seg_x2 deltas = struct.unpack('>%dh' % seg, self.data[base:base + seg_x2]) range_off_pos = base + seg_x2 offsets = struct.unpack( '>%dH' % seg, self.data[range_off_pos:range_off_pos + seg_x2]) for i in range(seg): for c in range(starts[i], min(ends[i], 0xFFFF) + 1): if offsets[i] == 0: gid = (c + deltas[i]) & 0xFFFF else: p = range_off_pos + i * 2 + offsets[i] + (c - starts[i]) * 2 gid = struct.unpack('>H', self.data[p:p + 2])[0] if gid: gid = (gid + deltas[i]) & 0xFFFF if gid: self.cmap[c] = gid else: n_groups = struct.unpack('>I', self.data[sub + 12:sub + 16])[0] for i in range(n_groups): p = sub + 16 + i * 12 s, e, g = struct.unpack('>III', self.data[p:p + 12]) for c in range(s, e + 1): self.cmap[c] = g + (c - s) def _read_fvar(self): off, _ = self.tables['fvar'] axes_off, _, axis_count, axis_size = struct.unpack( '>HHHH', self.data[off + 4:off + 12]) self.axes = [] for i in range(axis_count): p = off + axes_off + i * axis_size self.axes.append(self.data[p:p + 4].decode('latin1')) def _read_gvar(self): off, _ = self.tables['gvar'] axis_count, shared_count, shared_off, glyph_count, flags, data_off = ( struct.unpack('>HHIHHI', self.data[off + 4:off + 20])) base = off + 20 if flags & 1: raw = struct.unpack( '>%dI' % (glyph_count + 1), self.data[base:base + 4 * (glyph_count + 1)]) offsets = list(raw) else: raw = struct.unpack( '>%dH' % (glyph_count + 1), self.data[base:base + 2 * (glyph_count + 1)]) offsets = [v * 2 for v in raw] shared = [] p = off + shared_off for i in range(shared_count): step = 2 * axis_count shared.append(struct.unpack('>%dh' % axis_count, self.data[p + i * step:p + (i + 1) * step])) self.gvar = { 'axis_count': axis_count, 'shared': shared, 'offsets': offsets, 'data': off + data_off, } def _axis_deltas(self, gid, axis, contours): """Deltas that move the glyph to the `axis`=1 instance. Only tuples peaking on `axis` alone contribute: every other tuple is multiplied by an axis coordinate that stays at its default zero. """ gvar = self.gvar start = gvar['data'] + gvar['offsets'][gid] end = gvar['data'] + gvar['offsets'][gid + 1] if end <= start: return None d = self.data[start:end] axis_count = gvar['axis_count'] index = self.axes.index(axis) n_points = sum(len(c) for c in contours) + 4 tuple_count, cursor = struct.unpack('>HH', d[0:4]) shared_points = None if tuple_count & 0x8000: shared_points, cursor = _packed_points(d, cursor) total = [(0.0, 0.0)] * n_points applied = False p = 4 for _ in range(tuple_count & 0x0FFF): var_size, tuple_index = struct.unpack('>HH', d[p:p + 4]) p += 4 if tuple_index & 0x8000: peak = struct.unpack('>%dh' % axis_count, d[p:p + 2 * axis_count]) p += 2 * axis_count else: peak = gvar['shared'][tuple_index & 0x0FFF] if tuple_index & 0x4000: p += 4 * axis_count block, cursor = cursor, cursor + var_size if peak[index] <= 0 or any( v for i, v in enumerate(peak) if i != index): continue q = block points = shared_points if tuple_index & 0x2000: points, q = _packed_points(d, q) size = n_points if points is None else len(points) xs, q = _packed_deltas(d, q, size) ys, _ = _packed_deltas(d, q, size) scale = 16384.0 / peak[index] sparse = [None] * n_points for k, point in enumerate(range(size) if points is None else points): if point < n_points: sparse[point] = (xs[k] * scale, ys[k] * scale) _infer_deltas(contours, sparse) total = [(a[0] + b[0], a[1] + b[1]) for a, b in zip(total, sparse)] applied = True return total if applied else None def contours(self, codepoint, fill=0.0): gid = self.cmap[codepoint] contours = self._glyph_contours(gid) if fill <= 0: return contours if self._is_composite(gid): raise SystemExit('fill=1 не поддержан для составного глифа %04X' % codepoint) deltas = self._axis_deltas(gid, 'FILL', contours) if deltas is None: return contours out = [] index = 0 for contour in contours: shifted = [] for x, y, on in contour: dx, dy = deltas[index] index += 1 shifted.append((x + dx * fill, y + dy * fill, on)) out.append(shifted) return out def _is_composite(self, gid): goff, _ = self.tables['glyf'] start, end = self.loca[gid], self.loca[gid + 1] if start == end: return False return struct.unpack('>h', self.data[goff + start:goff + start + 2])[0] < 0 def _glyph_contours(self, gid, depth=0): goff, _ = self.tables['glyf'] start, end = self.loca[gid], self.loca[gid + 1] if start == end: return [] d = self.data[goff + start:goff + end] n_contours = struct.unpack('>h', d[0:2])[0] if n_contours < 0: return self._composite(d, depth) end_pts = struct.unpack('>%dH' % n_contours, d[10:10 + 2 * n_contours]) n_points = end_pts[-1] + 1 p = 10 + 2 * n_contours instr_len = struct.unpack('>H', d[p:p + 2])[0] p += 2 + instr_len flags = [] while len(flags) < n_points: f = d[p] p += 1 flags.append(f) if f & 8: rep = d[p] p += 1 flags.extend([f] * rep) flags = flags[:n_points] xs, x = [], 0 for f in flags: if f & 2: dx = d[p] p += 1 x += dx if f & 16 else -dx elif not f & 16: dx = struct.unpack('>h', d[p:p + 2])[0] p += 2 x += dx xs.append(x) ys, y = [], 0 for f in flags: if f & 4: dy = d[p] p += 1 y += dy if f & 32 else -dy elif not f & 32: dy = struct.unpack('>h', d[p:p + 2])[0] p += 2 y += dy ys.append(y) out, first = [], 0 for e in end_pts: pts = [(xs[i], ys[i], bool(flags[i] & 1)) for i in range(first, e + 1)] if pts: out.append(pts) first = e + 1 return out def _composite(self, d, depth): if depth > 4: return [] out = [] p = 10 while True: flags, glyph_index = struct.unpack('>HH', d[p:p + 4]) p += 4 if flags & 1: a1, a2 = struct.unpack('>hh', d[p:p + 4]) p += 4 else: a1, a2 = struct.unpack('>bb', d[p:p + 2]) p += 2 sx = sy = 1.0 s01 = s10 = 0.0 if flags & 8: sx = sy = _f2dot14(d, p) p += 2 elif flags & 0x40: sx = _f2dot14(d, p) sy = _f2dot14(d, p + 2) p += 4 elif flags & 0x80: sx = _f2dot14(d, p) s01 = _f2dot14(d, p + 2) s10 = _f2dot14(d, p + 4) sy = _f2dot14(d, p + 6) p += 8 dx, dy = (a1, a2) if flags & 2 else (0, 0) for contour in self._glyph_contours(glyph_index, depth + 1): out.append([ (x * sx + y * s10 + dx, x * s01 + y * sy + dy, on) for x, y, on in contour ]) if not flags & 0x20: break return out def _f2dot14(d, p): return struct.unpack('>h', d[p:p + 2])[0] / 16384.0 def _packed_points(d, p): """gvar packed point numbers; None means «все точки глифа».""" count = d[p] p += 1 if count == 0: return None, p if count & 0x80: count = ((count & 0x7F) << 8) | d[p] p += 1 points, value = [], 0 while len(points) < count: control = d[p] p += 1 run = (control & 0x7F) + 1 for _ in range(run): if control & 0x80: value += struct.unpack('>H', d[p:p + 2])[0] p += 2 else: value += d[p] p += 1 points.append(value) return points[:count], p def _packed_deltas(d, p, count): out = [] while len(out) < count: control = d[p] p += 1 run = (control & 0x3F) + 1 if control & 0x80: out.extend([0] * run) elif control & 0x40: for _ in range(run): out.append(struct.unpack('>h', d[p:p + 2])[0]) p += 2 else: for _ in range(run): out.append(struct.unpack('>b', d[p:p + 1])[0]) p += 1 return out[:count], p def _interpolate(v, v1, d1, v2, d2): if v1 > v2: v1, d1, v2, d2 = v2, d2, v1, d1 if v1 == v2: return d1 if d1 == d2 else 0.0 if v <= v1: return d1 if v >= v2: return d2 return d1 + (d2 - d1) * (v - v1) / (v2 - v1) def _infer_deltas(contours, deltas): """IUP: точки, которых нет в тапле, тянутся за соседними опорными.""" first = 0 for contour in contours: last = first + len(contour) - 1 refs = [i for i in range(first, last + 1) if deltas[i] is not None] if not refs: for i in range(first, last + 1): deltas[i] = (0.0, 0.0) elif len(refs) == 1: for i in range(first, last + 1): deltas[i] = deltas[refs[0]] else: for k, a in enumerate(refs): b = refs[(k + 1) % len(refs)] i = first if a == last else a + 1 while i != b: deltas[i] = ( _interpolate(contour[i - first][0], contour[a - first][0], deltas[a][0], contour[b - first][0], deltas[b][0]), _interpolate(contour[i - first][1], contour[a - first][1], deltas[a][1], contour[b - first][1], deltas[b][1]), ) i = first if i == last else i + 1 first = last + 1 for i, value in enumerate(deltas): if value is None: deltas[i] = (0.0, 0.0) def to_cubic(contour): """TrueType quadratic contour -> list of cubic segments [(p0,c1,c2,p1), ...].""" pts = [] for x, y, on in contour: pts.append((float(x), float(y), on)) if not pts[0][2]: if pts[-1][2]: pts = [pts[-1]] + pts[:-1] else: mx = (pts[0][0] + pts[-1][0]) / 2 my = (pts[0][1] + pts[-1][1]) / 2 pts = [(mx, my, True)] + pts expanded = [] for i, (x, y, on) in enumerate(pts): nx, ny, non = pts[(i + 1) % len(pts)] expanded.append((x, y, on)) if not on and not non: expanded.append(((x + nx) / 2, (y + ny) / 2, True)) segments = [] i = 0 n = len(expanded) while i < n: x0, y0, on0 = expanded[i] assert on0 x1, y1, on1 = expanded[(i + 1) % n] if on1: segments.append(((x0, y0), (x0, y0), (x1, y1), (x1, y1))) i += 1 else: x2, y2, _ = expanded[(i + 2) % n] c1 = (x0 + 2 / 3 * (x1 - x0), y0 + 2 / 3 * (y1 - y0)) c2 = (x2 + 2 / 3 * (x1 - x2), y2 + 2 / 3 * (y1 - y2)) segments.append(((x0, y0), c1, c2, (x2, y2))) i += 2 return segments def _find_font(): root = os.path.expanduser('~/.pub-cache/hosted/pub.dev') candidates = sorted( name for name in os.listdir(root) if name.startswith('material_symbols_icons-') ) if not candidates: raise SystemExit('material_symbols_icons не найден в pub-cache') return os.path.join(root, candidates[-1], 'lib', 'fonts', 'MaterialSymbolsOutlined.ttf') FONT = os.environ.get('MATERIAL_SYMBOLS_TTF') or _find_font() UPM = 960.0 CANVAS = 600.0 MIN_AREA = 500.0 _font = Font(FONT) def _bezier(seg, t): (x0, y0), (x1, y1), (x2, y2), (x3, y3) = seg mt = 1 - t x = mt ** 3 * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t ** 3 * x3 y = mt ** 3 * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t ** 3 * y3 return x, y def _split_cubic(seg, t): p0, c1, c2, p3 = seg def mid(a, b, k): return (a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k) a = mid(p0, c1, t) b = mid(c1, c2, t) c = mid(c2, p3, t) d = mid(a, b, t) e = mid(b, c, t) f = mid(d, e, t) return (p0, a, d, f), (f, e, c, p3) def _seg_metrics(seg, steps=64): pts = [_bezier(seg, i / steps) for i in range(steps + 1)] acc = [0.0] total = 0.0 for i in range(steps): total += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) acc.append(total) return acc, total, steps def _t_at_length(metrics, target): acc, total, steps = metrics if total <= 0: return 0.0 for i in range(steps): if acc[i + 1] >= target: span = acc[i + 1] - acc[i] k = 0.0 if span <= 0 else (target - acc[i]) / span return (i + k) / steps return 1.0 def _canvas_segments(contour): out = [] for seg in to_cubic(contour): out.append(tuple( (x / UPM * CANVAS, (1 - y / UPM) * CANVAS) for x, y in seg )) return out def _exact_path(contour, count): """Subdivide the original beziers: geometry stays bit-for-bit the glyph.""" segments = _canvas_segments(contour) metrics = [_seg_metrics(s) for s in segments] lengths = [m[1] for m in metrics] total = sum(lengths) if total <= 0: return [] quota = [max(1, int(round(count * length / total))) for length in lengths] while sum(quota) > count and max(quota) > 1: idx = max(range(len(quota)), key=lambda i: (quota[i], lengths[i])) quota[idx] -= 1 while sum(quota) < count: idx = max(range(len(quota)), key=lambda i: lengths[i] / quota[i]) quota[idx] += 1 pieces = [] for seg, metric, parts in zip(segments, metrics, quota): rest = seg consumed = 0.0 length = metric[1] for k in range(parts - 1): t_abs = _t_at_length(metric, length * (k + 1) / parts) span = 1.0 - consumed t_local = 0.0 if span <= 0 else (t_abs - consumed) / span t_local = min(max(t_local, 1e-4), 1 - 1e-4) head, rest = _split_cubic(rest, t_local) pieces.append(head) consumed = t_abs pieces.append(rest) path = [] n = len(pieces) for i, (p0, c1, _, _) in enumerate(pieces): prev_c2 = pieces[(i - 1) % n][2] path.append(( p0, (prev_c2[0] - p0[0], prev_c2[1] - p0[1]), (c1[0] - p0[0], c1[1] - p0[1]), )) return path def _area(path): area = 0.0 n = len(path) for i in range(n): x0, y0 = path[i][0] x1, y1 = path[(i + 1) % n][0] area += x0 * y1 - x1 * y0 return area / 2 def glyph_paths(codepoint, count, fill=0.0): out = [] for contour in _font.contours(codepoint, fill): path = _exact_path(contour, count) if not path: continue area = _area(path) if abs(area) < MIN_AREA: continue out.append((area, path)) return out def _centroid(path): return (sum(p[0][0] for p in path) / len(path), sum(p[0][1] for p in path) / len(path)) def _collapsed(path): cx, cy = _centroid(path) return [((cx, cy), (0.0, 0.0), (0.0, 0.0))] * len(path) def _rotate(path, shift): return path[shift:] + path[:shift] def _align(src, dst): n = len(src) best, best_cost = 0, None for shift in range(n): cost = 0.0 for i in range(n): x0, y0 = src[i][0] x1, y1 = dst[(i + shift) % n][0] cost += (x0 - x1) ** 2 + (y0 - y1) ** 2 if best_cost is None or cost < best_cost: best, best_cost = shift, cost return _rotate(dst, best) def outer_sign(shapes): """The biggest contour is always an outline: its winding defines 'outer'.""" biggest = max(shapes, key=lambda s: abs(s[0])) return 1.0 if biggest[0] > 0 else -1.0 def pair_glyphs(from_cp, to_cp, count, fill=0.0): """[(path_from, path_to), ...] with matching vertex counts and winding.""" src = glyph_paths(from_cp, count, fill) dst = glyph_paths(to_cp, count, fill) src_sign = outer_sign(src) dst_sign = outer_sign(dst) pairs = [] for outer in (True, False): a = sorted([s for s in src if (s[0] * src_sign > 0) == outer], key=lambda s: -abs(s[0])) b = sorted([s for s in dst if (s[0] * dst_sign > 0) == outer], key=lambda s: -abs(s[0])) for i in range(max(len(a), len(b))): if i < len(a) and i < len(b): pairs.append((a[i][1], _align(a[i][1], b[i][1]))) elif i < len(a): pairs.append((a[i][1], _collapsed(a[i][1]))) else: pairs.append((_collapsed(b[i][1]), b[i][1])) return pairs MIC = 0xE31D CAM = 0xE04B SEND = 0xE163 FLASH_ON = 0xE3E7 FLASH_OFF = 0xE3E6 POINTS = 56 FPS = 60 DUR = 24 OUT_DIR = os.path.join(os.path.dirname(os.path.dirname( os.path.abspath(__file__))), 'assets', 'lottie') EASE_OUT = {'x': 0.2, 'y': 0} EASE_IN = {'x': 0.0, 'y': 1.0} EASE_OUT_V = {'x': [0.2], 'y': [0]} EASE_IN_V = {'x': [0.0], 'y': [1.0]} EASE_SOFT_OUT_V = {'x': [0.33], 'y': [0]} EASE_SOFT_IN_V = {'x': [0.25], 'y': [1.0]} def r2(value): return round(value, 2) def path_value(path): return { 'i': [[r2(p[1][0]), r2(p[1][1])] for p in path], 'o': [[r2(p[2][0]), r2(p[2][1])] for p in path], 'v': [[r2(p[0][0]), r2(p[0][1])] for p in path], 'c': True, } COLLAPSE_END = 10 GROW_START = 12 def _is_point(path): first = path[0][0] return all(abs(p[0][0] - first[0]) < 0.01 and abs(p[0][1] - first[1]) < 0.01 for p in path) def shape_item(index, path_from, path_to): start, end = 0, DUR if _is_point(path_to): end = COLLAPSE_END elif _is_point(path_from): start = GROW_START return { 'ind': index, 'ty': 'sh', 'ix': index + 1, 'ks': { 'a': 1, 'k': [ {'i': EASE_IN, 'o': EASE_OUT, 't': start, 's': [path_value(path_from)]}, {'t': end, 's': [path_value(path_to)]}, ], 'ix': 2, }, 'nm': 'Path %d' % (index + 1), 'mn': 'ADBE Vector Shape - Group', 'hd': False, } def _group(items, name): items = list(items) items.append({ 'ty': 'fl', 'c': {'a': 0, 'k': [1, 1, 1, 1], 'ix': 4}, 'o': {'a': 0, 'k': 100, 'ix': 5}, 'r': 1, 'bm': 0, 'nm': 'Fill', 'mn': 'ADBE Vector Graphic - Fill', 'hd': False, }) items.append({ 'ty': 'tr', 'p': {'a': 0, 'k': [0, 0], 'ix': 2}, 'a': {'a': 0, 'k': [0, 0], 'ix': 1}, 's': {'a': 0, 'k': [100, 100], 'ix': 3}, 'r': {'a': 0, 'k': 0, 'ix': 6}, 'o': {'a': 0, 'k': 100, 'ix': 7}, 'sk': {'a': 0, 'k': 0, 'ix': 4}, 'sa': {'a': 0, 'k': 0, 'ix': 5}, 'nm': 'Transform', }) return { 'ty': 'gr', 'it': items, 'nm': name, 'np': len(items), 'cix': 2, 'bm': 0, 'ix': 1, 'mn': 'ADBE Vector Group', 'hd': False, } def static_shape(index, path): return { 'ind': index, 'ty': 'sh', 'ix': index + 1, 'ks': {'a': 0, 'k': path_value(path), 'ix': 2}, 'nm': 'Path %d' % (index + 1), 'mn': 'ADBE Vector Shape - Group', 'hd': False, } def keyframes(stops, vector): out = [] for i, (frame, value) in enumerate(stops): entry = {'t': frame, 's': value if isinstance(value, list) else [value]} if i < len(stops) - 1: if vector: entry['i'] = EASE_IN_V if i == 0 else EASE_SOFT_IN_V entry['o'] = EASE_OUT_V if i == 0 else EASE_SOFT_OUT_V else: entry['i'] = EASE_IN entry['o'] = EASE_OUT out.append(entry) return out def transform(rotation=None, scale=None, offset_x=None): half = CANVAS / 2 ks = { 'o': {'a': 0, 'k': 100, 'ix': 11}, 'r': {'a': 0, 'k': 0, 'ix': 10}, 'p': {'a': 0, 'k': [half, half, 0], 'ix': 2}, 'a': {'a': 0, 'k': [half, half, 0], 'ix': 1}, 's': {'a': 0, 'k': [100, 100, 100], 'ix': 6}, } if rotation: ks['r'] = {'a': 1, 'k': keyframes(rotation, vector=False), 'ix': 10} if scale: stops = [(f, [v, v, 100]) for f, v in scale] ks['s'] = {'a': 1, 'k': keyframes(stops, vector=True), 'ix': 6} if offset_x: stops = [(f, [half + dx, half, 0]) for f, dx in offset_x] ks['p'] = {'a': 1, 'k': keyframes(stops, vector=True), 'ix': 2} return ks def build(name, from_cp, to_cp, rotation=None, scale=None, offset_x=None, fill=0.0): pairs = pair_glyphs(from_cp, to_cp, POINTS, fill) items = [shape_item(i, a, b) for i, (a, b) in enumerate(pairs)] return { 'v': '5.12.1', 'fr': FPS, 'ip': 0, 'op': DUR, 'w': int(CANVAS), 'h': int(CANVAS), 'nm': name, 'ddd': 0, 'assets': [], 'layers': [{ 'ddd': 0, 'ind': 1, 'ty': 4, 'nm': name, 'sr': 1, 'ks': transform(rotation, scale, offset_x), 'ao': 0, 'shapes': [_group(items, 'Group 1')], 'ip': 0, 'op': DUR, 'st': 0, 'bm': 0, }], 'markers': [], } def _wipe_quad(cut, ahead): """Половина плоскости по обе стороны от диагонали x + y = cut.""" reach = CANVAS * 1.5 mid = (cut / 2, cut / 2) along = (reach / math.sqrt(2), -reach / math.sqrt(2)) depth = reach * math.sqrt(2) * (1 if ahead else -1) corners = [ (mid[0] + along[0], mid[1] + along[1]), (mid[0] - along[0], mid[1] - along[1]), (mid[0] - along[0] + depth, mid[1] - along[1] + depth), (mid[0] + along[0] + depth, mid[1] + along[1] + depth), ] return { 'i': [[0, 0]] * 4, 'o': [[0, 0]] * 4, 'v': [[r2(x), r2(y)] for x, y in corners], 'c': True, } def wipe_mask(span, ahead): start, end = span return [{ 'inv': False, 'mode': 'a', 'pt': { 'a': 1, 'k': [ {'i': EASE_IN, 'o': EASE_OUT, 't': 0, 's': [_wipe_quad(start, ahead)]}, {'t': DUR, 's': [_wipe_quad(end, ahead)]}, ], 'ix': 1, }, 'o': {'a': 0, 'k': 100, 'ix': 3}, 'x': {'a': 0, 'k': 0, 'ix': 4}, 'nm': 'Wipe', }] def _diagonal_span(*glyphs): values = [v[0][0] + v[0][1] for paths in glyphs for _, path in paths for v in path] margin = CANVAS * 0.04 return min(values) - margin, max(values) + margin def build_slash(name, plain_cp, slashed_cp, fill=0.0, scale=None): """Кадр 0 — обычный глиф, последний — перечёркнутый. Оба глифа лежат статичными слоями, а по диагонали (перпендикулярно самой перечёркивающей линии) едет маска: перечёркнутый слой открывается ровно там, где обычный закрывается, поэтому линия выглядит нарисованной поверх иконки. """ plain = glyph_paths(plain_cp, POINTS, fill) slashed = glyph_paths(slashed_cp, POINTS, fill) span = _diagonal_span(plain, slashed) def layer(index, paths, ahead, title): items = [static_shape(i, path) for i, (_, path) in enumerate(paths)] return { 'ddd': 0, 'ind': index, 'ty': 4, 'nm': title, 'sr': 1, 'ks': transform(scale=scale), 'ao': 0, 'hasMask': True, 'masksProperties': wipe_mask(span, ahead), 'shapes': [_group(items, title)], 'ip': 0, 'op': DUR, 'st': 0, 'bm': 0, } return { 'v': '5.12.1', 'fr': FPS, 'ip': 0, 'op': DUR, 'w': int(CANVAS), 'h': int(CANVAS), 'nm': name, 'ddd': 0, 'assets': [], 'layers': [ layer(1, slashed, False, 'slashed'), layer(2, plain, True, 'plain'), ], 'markers': [], } SPECS = [ dict( name='ic_mic_to_videocam', from_cp=MIC, to_cp=CAM, rotation=[(0, 0), (10, -14), (DUR, 0)], scale=[(0, 100), (10, 88), (DUR, 100)], ), dict( name='ic_videocam_to_mic', from_cp=CAM, to_cp=MIC, rotation=[(0, 0), (11, 14), (DUR, 0)], scale=[(0, 100), (11, 111), (DUR, 100)], ), dict( name='ic_mic_to_send', from_cp=MIC, to_cp=SEND, scale=[(0, 100), (9, 90), (DUR, 100)], offset_x=[(0, 0), (9, -34), (19, 12), (DUR, 0)], ), dict( name='ic_videocam_to_send', from_cp=CAM, to_cp=SEND, rotation=[(0, 0), (9, 10), (DUR, 0)], scale=[(0, 100), (9, 92), (DUR, 100)], offset_x=[(0, 0), (9, -26), (19, 10), (DUR, 0)], ), dict( name='ic_send_to_mic', from_cp=SEND, to_cp=MIC, rotation=[(0, 0), (10, 9), (DUR, 0)], scale=[(0, 100), (10, 91), (DUR, 100)], offset_x=[(0, 0), (10, 30), (19, -10), (DUR, 0)], ), dict( name='ic_send_to_videocam', from_cp=SEND, to_cp=CAM, rotation=[(0, 0), (10, -11), (DUR, 0)], scale=[(0, 100), (10, 90), (DUR, 100)], offset_x=[(0, 0), (10, 24), (19, -8), (DUR, 0)], ), ] SLASH_SPECS = [ dict( name='ic_flash_on_to_off', plain_cp=FLASH_ON, slashed_cp=FLASH_OFF, fill=1.0, scale=[(0, 100), (11, 92), (DUR, 100)], ), ] def _write(name, data): path = os.path.join(OUT_DIR, name + '.json') with open(path, 'w') as fh: json.dump(data, fh, separators=(',', ':')) print(f'{name:24s} {os.path.getsize(path) // 1024:3d} KB ' f'layers={len(data["layers"])}') def main(): os.makedirs(OUT_DIR, exist_ok=True) for spec in SPECS: _write(spec['name'], build(**spec)) for spec in SLASH_SPECS: _write(spec['name'], build_slash(**spec)) if __name__ == '__main__': main()