vector_shape.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  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. x = float(x)
  14. neg = math.copysign(1.0, x) < 0
  15. if neg:
  16. x = -x
  17. s = repr(x)
  18. if "e" in s or "E" in s:
  19. # The app-side expr tokenizer does not accept scientific
  20. # notation (glow trig produces values like 6.1e-17), so render
  21. # tiny/huge floats as plain decimals.
  22. s = f"{x:.30f}".rstrip("0")
  23. if s.endswith("."):
  24. s += "0"
  25. # The tokenizer also has no unary minus, so negative constants
  26. # (outline borders, glow trig) are rendered as subtraction.
  27. return f"(0 - {s})" if neg else s
  28. def _mul(a, b):
  29. return f"({_coord(a)} * {_coord(b)})"
  30. def _add(a, b):
  31. return f"({_coord(a)} + {_coord(b)})"
  32. class VectorShape:
  33. def __init__(self):
  34. # [x_expr, y_expr, [r, g, b, a]]
  35. self.verts = []
  36. self.indices = []
  37. def _vertex(self, x, y, color):
  38. self.verts.append([_coord(x), _coord(y), list(color)])
  39. def set(self, api, node_path, prop_name="shape", i=0):
  40. api.set_property_shape(node_path, prop_name, i, self.verts, self.indices)
  41. def join(self, other):
  42. off = len(self.verts)
  43. self.verts.extend([list(v) for v in other.verts])
  44. self.indices.extend([index + off for index in other.indices])
  45. def add_filled_box(self, x1, y1, x2, y2, color):
  46. self.add_gradient_box(x1, y1, x2, y2, [color, color, color, color])
  47. # Colors go clockwise from top-left
  48. def add_gradient_box(self, x1, y1, x2, y2, color):
  49. color = [list(c) for c in color]
  50. base = len(self.verts)
  51. self._vertex(x1, y1, color[0])
  52. self._vertex(x2, y1, color[1])
  53. self._vertex(x1, y2, color[3])
  54. self._vertex(x2, y2, color[2])
  55. self.indices.extend([base + 0, base + 2, base + 1, base + 1, base + 2, base + 3])
  56. # Create a smooth vertical gradient by subdividing into multiple strips.
  57. # gamma: low gamma below 0.5 is good
  58. def add_smooth_vertical_gradient(self, x1, y1, x2, y2, top_color, bottom_color, strips, gamma):
  59. for i in range(strips):
  60. t0 = i / strips
  61. t1 = (i + 1) / strips
  62. # Interpolate colors with gamma correction
  63. t0_color = t0 ** gamma
  64. t1_color = t1 ** gamma
  65. color_top = [top_color[j] + (bottom_color[j] - top_color[j]) * t0_color for j in range(4)]
  66. color_bottom = [top_color[j] + (bottom_color[j] - top_color[j]) * t1_color for j in range(4)]
  67. # Y coordinates use linear spacing (equal strip heights)
  68. y_top = _add(_mul(1.0 - t0, y1), _mul(t0, y2))
  69. y_bottom = _add(_mul(1.0 - t1, y1), _mul(t1, y2))
  70. self.add_gradient_box(
  71. x1,
  72. y_top,
  73. x2,
  74. y_bottom,
  75. [color_top, color_top, color_bottom, color_bottom],
  76. )
  77. def add_outline(self, x1, y1, x2, y2, border_px, color):
  78. # LHS
  79. self.add_filled_box(x1, y1, _add(x1, border_px), y2, color)
  80. # THS
  81. self.add_filled_box(x1, y1, x2, _add(y1, border_px), color)
  82. # RHS
  83. self.add_filled_box(_add(x2, -border_px), y1, x2, y2, color)
  84. # BHS
  85. self.add_filled_box(x1, _add(y2, -border_px), x2, y2, color)
  86. # Draw a line of a certain thickness between two points.
  87. # Coordinates are constants, so this does not track expressions like `w` or `h`.
  88. def add_line(self, from_x, from_y, to_x, to_y, thickness, color):
  89. dx = to_x - from_x
  90. dy = to_y - from_y
  91. length = math.sqrt(dx * dx + dy * dy)
  92. if length == 0.:
  93. return
  94. half = thickness / 2.
  95. px = -dy / length * half
  96. py = dx / length * half
  97. base = len(self.verts)
  98. self._vertex(from_x + px, from_y + py, color)
  99. self._vertex(to_x + px, to_y + py, color)
  100. self._vertex(from_x - px, from_y - py, color)
  101. self._vertex(to_x - px, to_y - py, color)
  102. self.indices.extend([base, base + 2, base + 1, base + 1, base + 2, base + 3])
  103. def add_radial_glow(self, center_x, center_y, width, height, segments, start_angle, end_angle, color):
  104. def ellipse_x(cos_angle):
  105. return _add(center_x, _mul(width, cos_angle * 0.5))
  106. def ellipse_y(sin_angle):
  107. return _add(center_y, _mul(height, sin_angle * 0.5))
  108. base = len(self.verts)
  109. self._vertex(center_x, center_y, color)
  110. arc_color = list(color)
  111. arc_color[3] = 0.
  112. for i in range(segments + 1):
  113. t = i / segments
  114. angle = start_angle + t * (end_angle - start_angle)
  115. self._vertex(ellipse_x(math.cos(angle)), ellipse_y(math.sin(angle)), arc_color)
  116. for i in range(segments):
  117. self.indices.extend([base, base + 1 + i, base + 2 + i])
  118. def scaled(self, scale):
  119. shape = VectorShape()
  120. shape.verts = [[_mul(scale, v[0]), _mul(scale, v[1]), list(v[2])] for v in self.verts]
  121. shape.indices = list(self.indices)
  122. return shape
  123. def offset(self, off_x, off_y):
  124. shape = VectorShape()
  125. shape.verts = [[_add(v[0], off_x), _add(v[1], off_y), list(v[2])] for v in self.verts]
  126. shape.indices = list(self.indices)
  127. return shape
  128. # python -m pydrk.vector_shape
  129. if __name__ == "__main__":
  130. assert _coord(6.123233995736766e-17) == f"{6.123233995736766e-17:.30f}".rstrip("0")
  131. assert _coord(-1.2246467991473532e-16) == f"(0 - {f'{1.2246467991473532e-16:.30f}'.rstrip('0')})"
  132. assert _coord(0.0) == "0.0"
  133. assert _coord(10) == "10.0"
  134. assert _coord(-4.0) == "(0 - 4.0)"
  135. shape = VectorShape()
  136. shape.add_filled_box("w/2", 0, "w", 10, [1., 0., 0., 1.])
  137. assert len(shape.verts) == 4 and len(shape.indices) == 6
  138. assert shape.verts[0][0] == "w/2" and shape.verts[2][1] == "10.0"
  139. assert shape.indices == [0, 2, 1, 1, 2, 3]
  140. shape = VectorShape()
  141. shape.add_smooth_vertical_gradient(0, 0, 10, 100, [1., 1., 1., 1.], [0., 0., 0., 0.], 8, 0.45)
  142. assert len(shape.verts) == 8 * 4 and len(shape.indices) == 8 * 6
  143. assert shape.verts[0][1] == "((1.0 * 0.0) + (0.0 * 100.0))"
  144. assert shape.verts[3][1] == "((0.875 * 0.0) + (0.125 * 100.0))"
  145. shape = VectorShape()
  146. shape.add_outline("x1", "y1", "x2", "y2", 2.0, [0., 0., 0., 1.])
  147. assert len(shape.verts) == 16 and len(shape.indices) == 24
  148. assert shape.verts[1][0] == "(x1 + 2.0)"
  149. assert shape.verts[8][0] == "(x2 + (0 - 2.0))"
  150. assert shape.verts[12][1] == "(y2 + (0 - 2.0))"
  151. shape = VectorShape()
  152. shape.add_line(0., 0., 10., 0., 4., [1., 1., 1., 1.])
  153. assert len(shape.verts) == 4 and len(shape.indices) == 6
  154. assert shape.verts[0][1] == "2.0" and shape.verts[2][1] == "(0 - 2.0)"
  155. shape = VectorShape()
  156. shape.add_radial_glow("w/2", "h/2", "w", "h", 12, 0., math.pi * 2., [1., 0., 0., 1.])
  157. assert len(shape.verts) == 14 and len(shape.indices) == 36
  158. assert shape.verts[0][0] == "w/2"
  159. assert shape.verts[1][0] == "(w/2 + (w * 0.5))"
  160. assert shape.verts[13][2][3] == 0.
  161. a = VectorShape()
  162. a.add_filled_box(0, 0, 1, 1, [1., 1., 1., 1.])
  163. b = VectorShape()
  164. b.add_filled_box(0, 0, 1, 1, [0., 0., 0., 1.])
  165. a.join(b)
  166. assert len(a.verts) == 8 and a.indices[6:] == [4, 6, 5, 5, 6, 7]
  167. s = a.scaled(2.5)
  168. assert s.verts[0][0] == "(2.5 * 0.0)"
  169. o = a.offset(10., 20.)
  170. assert o.verts[4][0] == "(0.0 + 10.0)" and o.verts[5][1] == "(0.0 + 20.0)"
  171. print("vector_shape self-test OK")