Coverage for pyrc \ core \ visualization \ viewer.py: 15%

97 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-13 16:59 +0200

1# ------------------------------------------------------------------------------- 

2# Copyright (C) 2026 Joel Kimmich, Tim Jourdan 

3# ------------------------------------------------------------------------------ 

4# License 

5# This file is part of PyRC, distributed under GPL-3.0-or-later. 

6# ------------------------------------------------------------------------------ 

7 

8from __future__ import annotations 

9 

10from typing import Iterable, TYPE_CHECKING 

11 

12from vpython import canvas, rate, color, vector, curve, arrow 

13import numpy as np 

14 

15 

16if TYPE_CHECKING: 

17 from pyrc.core.nodes import Node 

18 from pyrc.core.components.templates import Cell 

19 

20 

21class Viewer: 

22 def __init__( 

23 self, 

24 objects=None, 

25 title: str = "Cell Network", 

26 width: int = 1902, 

27 height: int = 963, 

28 background=color.black, 

29 autoscale: bool = False, 

30 wireframe: tuple[tuple[float | int, float | int, float | int], tuple[float | int, float | int, float | int]] 

31 | None = None, 

32 draw_coordinate_system: bool = True, 

33 ): 

34 """ 

35 Draw cells using vPython. 

36 

37 Parameters 

38 ---------- 

39 objects : list, optional 

40 The objects to draw. 

41 They can be added later on. 

42 title : str, optional 

43 The title of the canvas. 

44 width : int, optional 

45 Width of the canvas. 

46 height : int, optional 

47 Height of the canvas. 

48 background : color, optional 

49 Background color. 

50 autoscale : bool, optional 

51 Autoscale canvas. For a lot of objects not recommended. 

52 wireframe : tuple[tuple[float | int, float | int, float | int], tuple[float | int, float | int, float | int]] | None, optional 

53 If given, edges are drawn to display the boundaries of the network. 

54 Should consist of two 3D tuples, first is position/center, second width, height and depth. 

55 draw_coordinate_system : bool, optional 

56 If True, a coordinate system is drawn. 

57 """ 

58 self.scene = canvas(title=title, width=width, height=height, background=background) 

59 self.scene.autoscale = autoscale # keep False for performance with many objects 

60 

61 self.initial_color = (0.6, 0.6, 0.6) 

62 

63 self.objects = [] 

64 if objects: 

65 self.add(objects) 

66 

67 self.wireframe_dimensions = None 

68 if wireframe is not None: 

69 self.draw_wireframe(*wireframe) 

70 self.wireframe_dimensions = wireframe[1] 

71 

72 if draw_coordinate_system: 

73 self.draw_coordinate_system() 

74 

75 def draw_wireframe(self, center, deltas, edge_color=color.white, edge_radius_factor=0.001): 

76 """ 

77 Draw wireframe edges of the grid bounding box. 

78 

79 Parameters 

80 ---------- 

81 center : tuple[float | int, float | int] 

82 The center of the wireframe. 

83 deltas : tuple[float | int, float | int] 

84 The width, height and depth of the wireframe. 

85 edge_color : color, optional 

86 The color of the edges. 

87 edge_radius_factor : float, optional 

88 Determines the thickness of the edges drawn. 

89 Is multiplied by the maximum dimension (x/y/z value). 

90 """ 

91 cx, cy, cz = center 

92 dx, dy, dz = deltas 

93 

94 # Calculate corner positions 

95 half_dx, half_dy, half_dz = dx / 2, dy / 2, dz / 2 

96 

97 corners = [ 

98 vector(cx - half_dx, cy - half_dy, cz - half_dz), 

99 vector(cx + half_dx, cy - half_dy, cz - half_dz), 

100 vector(cx + half_dx, cy + half_dy, cz - half_dz), 

101 vector(cx - half_dx, cy + half_dy, cz - half_dz), 

102 vector(cx - half_dx, cy - half_dy, cz + half_dz), 

103 vector(cx + half_dx, cy - half_dy, cz + half_dz), 

104 vector(cx + half_dx, cy + half_dy, cz + half_dz), 

105 vector(cx - half_dx, cy + half_dy, cz + half_dz), 

106 ] 

107 

108 # Define edges as pairs of corner indices 

109 edges = [ 

110 (0, 1), 

111 (1, 2), 

112 (2, 3), 

113 (3, 0), # Bottom face 

114 (4, 5), 

115 (5, 6), 

116 (6, 7), 

117 (7, 4), # Top face 

118 (0, 4), 

119 (1, 5), 

120 (2, 6), 

121 (3, 7), # Vertical edges 

122 ] 

123 

124 # Draw each edge 

125 for i, j in edges: 

126 curve(pos=[corners[i], corners[j]], color=edge_color, radius=max(dx, dy, dz) * edge_radius_factor) 

127 

128 # Set camera position and orientation 

129 # Position camera to look at grid from an angle 

130 distance = max(dx, dy, dz) * 2.5 

131 self.scene.camera.pos = vector(cx - distance, cy - distance, cz + distance * 0.7) 

132 self.scene.camera.axis = vector(cx, cy, cz) - self.scene.camera.pos 

133 

134 # Set up vector so z points up 

135 self.scene.up = vector(0, 0, 1) 

136 

137 # Set the center point 

138 self.scene.center = vector(cx, cy, cz) 

139 

140 # Adjust range to fit the grid 

141 self.scene.range = max(dx, dy, dz) * 0.8 

142 

143 def draw_coordinate_system(self): 

144 if self.wireframe_dimensions is not None: 

145 length = min(self.wireframe_dimensions) * 0.25 

146 else: 

147 length = 0.5 

148 

149 arrow_color = color.orange 

150 for _dir in [(1, 0, 0), (0, 1, 0), (0, 0, 1)]: 

151 direction = np.array(_dir) * length 

152 arrow(pos=vector(0, 0, 0), axis=vector(*direction), color=arrow_color) 

153 

154 def add(self, objects: list | tuple | Cell, rgb=None): 

155 if not isinstance(objects, list | tuple): 

156 objects = [objects] 

157 if rgb is None: 

158 rgb = self.initial_color 

159 

160 o: Cell 

161 for o in objects: 

162 box = o.vbox 

163 box.pos = vector(*o.position) 

164 box.size = vector(*o.delta) 

165 box.color = vector(*rgb) 

166 box.opacity = o.opacity 

167 self.objects.append(o) 

168 

169 rate(1000) 

170 

171 def add_new_from_list(self, objects: list | tuple): 

172 """ 

173 Like add_highlight, but first determine which of the objects are already added and only highlight new ones. 

174 

175 Parameters 

176 ---------- 

177 objects : list | tuple 

178 The list with objects, where new objects have been added and the new ones should be highlighted. 

179 """ 

180 new_list = [] 

181 for o in objects: 

182 if o not in self.objects: 

183 new_list.append(o) 

184 

185 self.add_highlight(new_list) 

186 

187 def add_highlight( 

188 self, new_objects: list | tuple | Cell, highlight_rgb=(1, 51 / 255, 99 / 255), opacity=1.0, old_opacity=0.5 

189 ): 

190 for o in self.objects: 

191 o.vbox.color = vector(*self.initial_color) 

192 o.opacity = old_opacity 

193 o.vbox.opacity = old_opacity 

194 

195 if not isinstance(new_objects, list | tuple): 

196 new_objects = [new_objects] 

197 

198 for o in new_objects: 

199 o.opacity = opacity 

200 self.add(o, rgb=highlight_rgb) 

201 

202 rate(1000) 

203 

204 @staticmethod 

205 def update_all_colors( 

206 nodes: Iterable[Node], 

207 *, 

208 t_min: float, 

209 t_max: float, 

210 fps: float = 60.0, 

211 ) -> None: 

212 """ 

213 Color-only update pass. Use inside your simulation/animation loop. 

214 """ 

215 rate(fps) 

216 for n in nodes: 

217 n.update_color(t_min=t_min, t_max=t_max) 

218 

219 def update_all_geometry(self, fps: float = 60.0) -> None: 

220 """ 

221 Geometry update pass (pos/size). Only use if geometry actually changes. 

222 """ 

223 rate(fps) 

224 for c in self.objects: 

225 c.update_vbox_geometry() 

226 

227 # def animate_temperature_series( 

228 # self, 

229 # cells: Sequence[Cell], 

230 # temp_frames: Sequence[Sequence[float]], 

231 # *, 

232 # fps: float = 1.0, 

233 # loop: bool = False, 

234 # ) -> None: 

235 # """ 

236 # Animate temperatures over time. 

237 # 

238 # temp_frames : 

239 # Sequence of frames; each frame is a sequence of temperatures 

240 # with length == len(cells). 

241 # Example: one frame per 5 min sim step, displayed at 1 fps. 

242 # fps : 

243 # How many frames per second you want to display. 

244 # Your example: fps=1.0 (1 second represents 5 minutes of simulation). 

245 # """ 

246 # n = len(cells) 

247 # if n == 0: 

248 # return 

249 # 

250 # # Ensure geometry exists before animating 

251 # self.build_geometry(cells) 

252 # 

253 # # Basic sanity check once (cheap) 

254 # for k, frame in enumerate(temp_frames[:1]): 

255 # if len(frame) != n: 

256 # raise ValueError(f"Frame {k} length {len(frame)} != number of cells {n}") 

257 # 

258 # while True: 

259 # for frame in temp_frames: 

260 # rate(fps) 

261 # # assign temps 

262 # # update colors only 

263 # for c, T in zip(cells, frame): 

264 # c.update_color(temperature=T) 

265 # 

266 # if not loop: 

267 # break 

268 

269 

270# ---------------------------- 

271# Example usage (remove in your project) 

272# ---------------------------- 

273 

274if __name__ == "__main__": 

275 # Build a toy grid of cells 

276 _cells: list[Cell] = [] 

277 nx, ny, nz = 20, 20, 25 # 20*20*25 = 10,000 boxes 

278 spacing = 1.1 

279 size = (1.0, 1.0, 1.0) 

280 

281 from pyrc.core.components.templates import Cell 

282 

283 for ix in range(nx): 

284 for iy in range(ny): 

285 for iz in range(nz): 

286 _cells.append(Cell(position=np.array((ix * spacing, iy * spacing, iz * spacing)), delta=(1, 1, 1))) 

287 

288 _viewer = Viewer(autoscale=False) 

289 

290 # Fake temperature frames: 120 frames (e.g., 120 * 5min = 10 hours simulated) 

291 # One frame per second in the animation 

292 import math 

293 

294 _frames = [] 

295 for k in range(120): 

296 _frame = [] 

297 for _i in range(len(_cells)): 

298 # arbitrary smooth variation 

299 _frame.append(50.0 + 50.0 * math.sin(0.02 * _i + 0.2 * k)) 

300 _frames.append(_frame) 

301 

302 # _viewer.animate_temperature_series( 

303 # _cells, 

304 # _frames, 

305 # t_min=0.0, 

306 # t_max=100.0, 

307 # fps=1.0, # 1 second per frame (your "5 min sim step" display) 

308 # loop=True, 

309 # )