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
« 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# ------------------------------------------------------------------------------
8from __future__ import annotations
10from typing import Iterable, TYPE_CHECKING
12from vpython import canvas, rate, color, vector, curve, arrow
13import numpy as np
16if TYPE_CHECKING:
17 from pyrc.core.nodes import Node
18 from pyrc.core.components.templates import Cell
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.
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
61 self.initial_color = (0.6, 0.6, 0.6)
63 self.objects = []
64 if objects:
65 self.add(objects)
67 self.wireframe_dimensions = None
68 if wireframe is not None:
69 self.draw_wireframe(*wireframe)
70 self.wireframe_dimensions = wireframe[1]
72 if draw_coordinate_system:
73 self.draw_coordinate_system()
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.
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
94 # Calculate corner positions
95 half_dx, half_dy, half_dz = dx / 2, dy / 2, dz / 2
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 ]
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 ]
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)
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
134 # Set up vector so z points up
135 self.scene.up = vector(0, 0, 1)
137 # Set the center point
138 self.scene.center = vector(cx, cy, cz)
140 # Adjust range to fit the grid
141 self.scene.range = max(dx, dy, dz) * 0.8
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
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)
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
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)
169 rate(1000)
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.
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)
185 self.add_highlight(new_list)
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
195 if not isinstance(new_objects, list | tuple):
196 new_objects = [new_objects]
198 for o in new_objects:
199 o.opacity = opacity
200 self.add(o, rgb=highlight_rgb)
202 rate(1000)
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)
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()
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
270# ----------------------------
271# Example usage (remove in your project)
272# ----------------------------
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)
281 from pyrc.core.components.templates import Cell
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)))
288 _viewer = Viewer(autoscale=False)
290 # Fake temperature frames: 120 frames (e.g., 120 * 5min = 10 hours simulated)
291 # One frame per second in the animation
292 import math
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)
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 # )