vector_shape.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. """Python reimplementation of the shape-building routines from
  2. src/ui/vector_art/shape.rs.
  3. Coordinates are expr source strings ("w/2", "h - 10") or numbers (normalized
  4. to float literals), matching the wire format of Api.set_property_shape: the
  5. app compiles and evaluates them server-side, so there is no client-side
  6. eval here. scaled()/offset() wrap coordinates in arithmetic just like the
  7. app-side op surgery.
  8. """
  9. import math
  10. def _coord(x):
  11. if isinstance(x, str):
  12. return x
  13. return repr(float(x))
  14. def _mul(a, b):
  15. return f"({_coord(a)} * {_coord(b)})"
  16. def _add(a, b):
  17. return f"({_coord(a)} + {_coord(b)})"
  18. class VectorShape:
  19. def __init__(self):
  20. # [x_expr, y_expr, [r, g, b, a]]
  21. self.verts = []
  22. self.indices = []
  23. def _vertex(self, x, y, color):
  24. self.verts.append([_coord(x), _coord(y), list(color)])
  25. def set(self, api, node_path, prop_name="shape", i=0):
  26. api.set_property_shape(node_path, prop_name, i, self.verts, self.indices)
  27. def join(self, other):
  28. off = len(self.verts)
  29. self.verts.extend([list(v) for v in other.verts])
  30. self.indices.extend([index + off for index in other.indices])
  31. def add_filled_box(self, x1, y1, x2, y2, color):
  32. self.add_gradient_box(x1, y1, x2, y2, [color, color, color, color])
  33. # Colors go clockwise from top-left
  34. def add_gradient_box(self, x1, y1, x2, y2, color):
  35. color = [list(c) for c in color]
  36. base = len(self.verts)
  37. self._vertex(x1, y1, color[0])
  38. self._vertex(x2, y1, color[1])
  39. self._vertex(x1, y2, color[3])
  40. self._vertex(x2, y2, color[2])
  41. self.indices.extend([base + 0, base + 2, base + 1, base + 1, base + 2, base + 3])
  42. # Create a smooth vertical gradient by subdividing into multiple strips.
  43. # gamma: low gamma below 0.5 is good
  44. def add_smooth_vertical_gradient(self, x1, y1, x2, y2, top_color, bottom_color, strips, gamma):
  45. for i in range(strips):
  46. t0 = i / strips
  47. t1 = (i + 1) / strips
  48. # Interpolate colors with gamma correction
  49. t0_color = t0 ** gamma
  50. t1_color = t1 ** gamma
  51. color_top = [top_color[j] + (bottom_color[j] - top_color[j]) * t0_color for j in range(4)]
  52. color_bottom = [top_color[j] + (bottom_color[j] - top_color[j]) * t1_color for j in range(4)]
  53. # Y coordinates use linear spacing (equal strip heights)
  54. y_top = _add(_mul(1.0 - t0, y1), _mul(t0, y2))
  55. y_bottom = _add(_mul(1.0 - t1, y1), _mul(t1, y2))
  56. self.add_gradient_box(
  57. x1,
  58. y_top,
  59. x2,
  60. y_bottom,
  61. [color_top, color_top, color_bottom, color_bottom],
  62. )
  63. def add_outline(self, x1, y1, x2, y2, border_px, color):
  64. # LHS
  65. self.add_filled_box(x1, y1, _add(x1, border_px), y2, color)
  66. # THS
  67. self.add_filled_box(x1, y1, x2, _add(y1, border_px), color)
  68. # RHS
  69. self.add_filled_box(_add(x2, -border_px), y1, x2, y2, color)
  70. # BHS
  71. self.add_filled_box(x1, _add(y2, -border_px), x2, y2, color)
  72. # Draw a line of a certain thickness between two points.
  73. # Coordinates are constants, so this does not track expressions like `w` or `h`.
  74. def add_line(self, from_x, from_y, to_x, to_y, thickness, color):
  75. dx = to_x - from_x
  76. dy = to_y - from_y
  77. length = math.sqrt(dx * dx + dy * dy)
  78. if length == 0.:
  79. return
  80. half = thickness / 2.
  81. px = -dy / length * half
  82. py = dx / length * half
  83. base = len(self.verts)
  84. self._vertex(from_x + px, from_y + py, color)
  85. self._vertex(to_x + px, to_y + py, color)
  86. self._vertex(from_x - px, from_y - py, color)
  87. self._vertex(to_x - px, to_y - py, color)
  88. self.indices.extend([base, base + 2, base + 1, base + 1, base + 2, base + 3])
  89. def add_radial_glow(self, center_x, center_y, width, height, segments, start_angle, end_angle, color):
  90. def ellipse_x(cos_angle):
  91. return _add(center_x, _mul(width, cos_angle * 0.5))
  92. def ellipse_y(sin_angle):
  93. return _add(center_y, _mul(height, sin_angle * 0.5))
  94. base = len(self.verts)
  95. self._vertex(center_x, center_y, color)
  96. arc_color = list(color)
  97. arc_color[3] = 0.
  98. for i in range(segments + 1):
  99. t = i / segments
  100. angle = start_angle + t * (end_angle - start_angle)
  101. self._vertex(ellipse_x(math.cos(angle)), ellipse_y(math.sin(angle)), arc_color)
  102. for i in range(segments):
  103. self.indices.extend([base, base + 1 + i, base + 2 + i])
  104. def scaled(self, scale):
  105. shape = VectorShape()
  106. shape.verts = [[_mul(scale, v[0]), _mul(scale, v[1]), list(v[2])] for v in self.verts]
  107. shape.indices = list(self.indices)
  108. return shape
  109. def offset(self, off_x, off_y):
  110. shape = VectorShape()
  111. shape.verts = [[_add(v[0], off_x), _add(v[1], off_y), list(v[2])] for v in self.verts]
  112. shape.indices = list(self.indices)
  113. return shape
  114. # python -m pydrk.vector_shape
  115. if __name__ == "__main__":
  116. shape = VectorShape()
  117. shape.add_filled_box("w/2", 0, "w", 10, [1., 0., 0., 1.])
  118. assert len(shape.verts) == 4 and len(shape.indices) == 6
  119. assert shape.verts[0][0] == "w/2" and shape.verts[2][1] == "10.0"
  120. assert shape.indices == [0, 2, 1, 1, 2, 3]
  121. shape = VectorShape()
  122. shape.add_smooth_vertical_gradient(0, 0, 10, 100, [1., 1., 1., 1.], [0., 0., 0., 0.], 8, 0.45)
  123. assert len(shape.verts) == 8 * 4 and len(shape.indices) == 8 * 6
  124. assert shape.verts[0][1] == "((1.0 * 0.0) + (0.0 * 100.0))"
  125. assert shape.verts[3][1] == "((0.875 * 0.0) + (0.125 * 100.0))"
  126. shape = VectorShape()
  127. shape.add_outline("x1", "y1", "x2", "y2", 2.0, [0., 0., 0., 1.])
  128. assert len(shape.verts) == 16 and len(shape.indices) == 24
  129. assert shape.verts[1][0] == "(x1 + 2.0)"
  130. assert shape.verts[8][0] == "(x2 + -2.0)"
  131. assert shape.verts[12][1] == "(y2 + -2.0)"
  132. shape = VectorShape()
  133. shape.add_line(0., 0., 10., 0., 4., [1., 1., 1., 1.])
  134. assert len(shape.verts) == 4 and len(shape.indices) == 6
  135. assert shape.verts[0][1] == "2.0" and shape.verts[2][1] == "-2.0"
  136. shape = VectorShape()
  137. shape.add_radial_glow("w/2", "h/2", "w", "h", 12, 0., math.pi * 2., [1., 0., 0., 1.])
  138. assert len(shape.verts) == 14 and len(shape.indices) == 36
  139. assert shape.verts[0][0] == "w/2"
  140. assert shape.verts[1][0] == "(w/2 + (w * 0.5))"
  141. assert shape.verts[13][2][3] == 0.
  142. a = VectorShape()
  143. a.add_filled_box(0, 0, 1, 1, [1., 1., 1., 1.])
  144. b = VectorShape()
  145. b.add_filled_box(0, 0, 1, 1, [0., 0., 0., 1.])
  146. a.join(b)
  147. assert len(a.verts) == 8 and a.indices[6:] == [4, 6, 5, 5, 6, 7]
  148. s = a.scaled(2.5)
  149. assert s.verts[0][0] == "(2.5 * 0.0)"
  150. o = a.offset(10., 20.)
  151. assert o.verts[4][0] == "(0.0 + 10.0)" and o.verts[5][1] == "(0.0 + 20.0)"
  152. print("vector_shape self-test OK")