From 28d26fad057ce00b420b455ad17e11127d6385d4 Mon Sep 17 00:00:00 2001 From: Jelle Feringa Date: Wed, 17 Dec 2025 22:30:33 +0100 Subject: [PATCH] perf: uniform caching, dirty flags, numpy mesh path --- CHANGELOG.md | 8 ++ src/compas_viewer/renderer/camera.py | 5 +- src/compas_viewer/renderer/renderer.py | 91 +++++++++------ src/compas_viewer/renderer/shaders/shader.py | 19 ++- src/compas_viewer/scene/buffermanager.py | 83 ++++++++++--- src/compas_viewer/scene/meshobject.py | 110 +++++++++++++++++- src/compas_viewer/scene/sceneobject.py | 116 +++++++++++++++++-- 7 files changed, 358 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3429af944a..3fe4d1d752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added numpy fast path for triangulated mesh data (`_read_frontfaces_data_numpy`, `_read_backfaces_data_numpy`). +* Added dirty flag tracking for settings updates in `BufferManager`. + ### Changed * Made `linewidth` working again through `GeometryShader`. +* Cached uniform locations in `Shader` to avoid per-frame lookups. +* Cached instance color FBO in `Renderer` to avoid per-selection allocation. +* `BufferManager._add_buffer_data` now accepts numpy arrays directly. +* Scene object settings converted to properties with dirty tracking for efficient GPU updates. +* Fixed assertion error in `Camera.projection` when scale is 0. ### Removed diff --git a/src/compas_viewer/renderer/camera.py b/src/compas_viewer/renderer/camera.py index 31bd94c55e..da96e27244 100644 --- a/src/compas_viewer/renderer/camera.py +++ b/src/compas_viewer/renderer/camera.py @@ -395,15 +395,16 @@ def projection(self, width: int, height: int) -> list[list[float]]: """ aspect = width / height + scale = max(self.scale, 1e-6) # Prevent near == far when scale is 0 if self.renderer.view == "perspective": - P = self.perspective(self.fov, aspect, self.near * self.scale, self.far * self.scale) + P = self.perspective(self.fov, aspect, self.near * scale, self.far * scale) else: left = -self.distance right = self.distance bottom = -self.distance / aspect top = self.distance / aspect - P = self.ortho(left, right, bottom, top, self.near * self.scale, self.far * self.scale) + P = self.ortho(left, right, bottom, top, self.near * scale, self.far * scale) return asfortranarray(P, dtype=float32) diff --git a/src/compas_viewer/renderer/renderer.py b/src/compas_viewer/renderer/renderer.py index e76b291933..e1f7c5f6b1 100644 --- a/src/compas_viewer/renderer/renderer.py +++ b/src/compas_viewer/renderer/renderer.py @@ -86,6 +86,12 @@ def __init__(self, viewer: "Viewer"): self.buffer_manager = BufferManager() + # Cached FBO for instance color picking + self._instance_fbo = None + self._instance_texture = None + self._instance_depth = None + self._instance_fbo_size = (0, 0) + @property def rendermode(self): """ @@ -588,8 +594,53 @@ def paint(self, is_instance: bool = False): # Unbind once we're done GL.glBindVertexArray(0) + def _ensure_instance_fbo(self, width: int, height: int): + """Create or resize instance picking FBO as needed.""" + if self._instance_fbo_size == (width, height) and self._instance_fbo is not None: + return # Already have correct size FBO + + # Clean up old resources if they exist + if self._instance_fbo is not None: + GL.glDeleteFramebuffers(1, [self._instance_fbo]) + GL.glDeleteTextures(1, [self._instance_texture]) + GL.glDeleteRenderbuffers(1, [self._instance_depth]) + + # Create new FBO + self._instance_fbo = GL.glGenFramebuffers(1) + GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self._instance_fbo) + + # Create texture + self._instance_texture = GL.glGenTextures(1) + GL.glBindTexture(GL.GL_TEXTURE_2D, self._instance_texture) + GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, width, height, 0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, None) + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST) + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST) + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE) + GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE) + GL.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, GL.GL_TEXTURE_2D, self._instance_texture, 0) + + # Create depth buffer + self._instance_depth = GL.glGenRenderbuffers(1) + GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, self._instance_depth) + GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT24, width, height) + GL.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, GL.GL_RENDERBUFFER, self._instance_depth) + + # Check if FBO is complete + status = GL.glCheckFramebufferStatus(GL.GL_FRAMEBUFFER) + if status != GL.GL_FRAMEBUFFER_COMPLETE: + GL.glDeleteRenderbuffers(1, [self._instance_depth]) + GL.glDeleteTextures(1, [self._instance_texture]) + GL.glDeleteFramebuffers(1, [self._instance_fbo]) + self._instance_fbo = None + self._instance_texture = None + self._instance_depth = None + self._instance_fbo_size = (0, 0) + raise Exception(f"Framebuffer is not complete! Status: {status}") + + self._instance_fbo_size = (width, height) + GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, 0) + def read_instance_color(self, box: tuple[int, int, int, int]): - # TODO: Should be able to massively simplify this. # Get the rectangle area x1, y1, x2, y2 = box x, y = min(x1, x2), self.height() - max(y1, y2) @@ -600,34 +651,9 @@ def read_instance_color(self, box: tuple[int, int, int, int]): viewport = GL.glGetIntegerv(GL.GL_VIEWPORT) previous_fbo = GL.glGetIntegerv(GL.GL_FRAMEBUFFER_BINDING) - # Create an FBO with original window size (not scaled) - fbo = GL.glGenFramebuffers(1) - GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, fbo) - - # Create a texture to attach to the FBO - texture = GL.glGenTextures(1) - GL.glBindTexture(GL.GL_TEXTURE_2D, texture) - GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGBA8, self.width(), self.height(), 0, GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, None) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_NEAREST) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_NEAREST) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE) - GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE) - GL.glFramebufferTexture2D(GL.GL_FRAMEBUFFER, GL.GL_COLOR_ATTACHMENT0, GL.GL_TEXTURE_2D, texture, 0) - - # Create and attach depth buffer - depth_buffer = GL.glGenRenderbuffers(1) - GL.glBindRenderbuffer(GL.GL_RENDERBUFFER, depth_buffer) - GL.glRenderbufferStorage(GL.GL_RENDERBUFFER, GL.GL_DEPTH_COMPONENT24, self.width(), self.height()) - GL.glFramebufferRenderbuffer(GL.GL_FRAMEBUFFER, GL.GL_DEPTH_ATTACHMENT, GL.GL_RENDERBUFFER, depth_buffer) - - # Check if FBO is complete - status = GL.glCheckFramebufferStatus(GL.GL_FRAMEBUFFER) - if status != GL.GL_FRAMEBUFFER_COMPLETE: - GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, previous_fbo) - GL.glDeleteRenderbuffers(1, [depth_buffer]) - GL.glDeleteTextures(1, [texture]) - GL.glDeleteFramebuffers(1, [fbo]) - raise Exception(f"Framebuffer is not complete! Status: {status}") + # Ensure we have a properly sized FBO + self._ensure_instance_fbo(self.width(), self.height()) + GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, self._instance_fbo) # Set up rendering state GL.glViewport(0, 0, self.width(), self.height()) @@ -702,13 +728,8 @@ def read_instance_color(self, box: tuple[int, int, int, int]): if not prev_depth_test: GL.glDisable(GL.GL_DEPTH_TEST) - # Clean up + # Restore previous FBO and viewport (FBO resources are cached, not deleted) GL.glBindFramebuffer(GL.GL_FRAMEBUFFER, previous_fbo) - GL.glDeleteRenderbuffers(1, [depth_buffer]) - GL.glDeleteTextures(1, [texture]) - GL.glDeleteFramebuffers(1, [fbo]) - - # Restore viewport GL.glViewport(*viewport) return box_map.reshape(-1, 3) diff --git a/src/compas_viewer/renderer/shaders/shader.py b/src/compas_viewer/renderer/shaders/shader.py index f42de7a8d4..ac8c5e321f 100644 --- a/src/compas_viewer/renderer/shaders/shader.py +++ b/src/compas_viewer/renderer/shaders/shader.py @@ -12,6 +12,13 @@ class Shader: def __init__(self, name: str = "mesh"): self.program = make_shader_program(name) self.locations = {} + self._uniform_locations = {} + + def _get_uniform_location(self, name: str) -> int: + """Get cached uniform location, looking up only once per uniform name.""" + if name not in self._uniform_locations: + self._uniform_locations[name] = GL.glGetUniformLocation(self.program, name) + return self._uniform_locations[name] def uniform4x4(self, name: str, value: list[list[float]]): """Store a uniform 4x4 transformation matrix in the shader program at a named location. @@ -24,7 +31,7 @@ def uniform4x4(self, name: str, value: list[list[float]]): A 4x4 transformation matrix. """ _value = array(value) - location = GL.glGetUniformLocation(self.program, name) + location = self._get_uniform_location(name) GL.glUniformMatrix4fv(location, 1, True, _value) def uniform1i(self, name: str, value: int): @@ -37,7 +44,7 @@ def uniform1i(self, name: str, value: int): value : int An integer value. """ - location = GL.glGetUniformLocation(self.program, name) + location = self._get_uniform_location(name) GL.glUniform1i(location, value) def uniform1f(self, name: str, value: float): @@ -50,7 +57,7 @@ def uniform1f(self, name: str, value: float): value : float A float value. """ - location = GL.glGetUniformLocation(self.program, name) + location = self._get_uniform_location(name) GL.glUniform1f(location, value) def uniform3f(self, name: str, value: Union[tuple[float, float, float], list[float]]): @@ -63,7 +70,7 @@ def uniform3f(self, name: str, value: Union[tuple[float, float, float], list[flo value : Union[tuple[float, float, float], list[float]] An iterable of 3 floats. """ - location = GL.glGetUniformLocation(self.program, name) + location = self._get_uniform_location(name) GL.glUniform3f(location, *value) def uniform2f(self, name: str, value: Union[tuple[float, float], list[float]]): @@ -76,7 +83,7 @@ def uniform2f(self, name: str, value: Union[tuple[float, float], list[float]]): value : Union[tuple[float, float], list[float]] An iterable of 2 floats. """ - location = GL.glGetUniformLocation(self.program, name) + location = self._get_uniform_location(name) GL.glUniform2f(location, *value) def uniformText(self, name: str, texture: Any): @@ -106,7 +113,7 @@ def uniformBuffer(self, name: str, buffer: Any, unit: int = 0): unit : int The texture unit to use (0-15 typically available) """ - location = GL.glGetUniformLocation(self.program, name) + location = self._get_uniform_location(name) GL.glUniform1i(location, unit) # Use specified texture unit GL.glActiveTexture(GL.GL_TEXTURE0 + unit) GL.glBindTexture(GL.GL_TEXTURE_BUFFER, buffer) diff --git a/src/compas_viewer/scene/buffermanager.py b/src/compas_viewer/scene/buffermanager.py index 77023deedd..deec480d63 100644 --- a/src/compas_viewer/scene/buffermanager.py +++ b/src/compas_viewer/scene/buffermanager.py @@ -60,6 +60,9 @@ def __init__(self): self.settings: List[float] = [] self.object_settings_cache: Dict[Any, List[float]] = {} + # Dirty tracking for settings updates + self._dirty_settings: set = set() + # Initialize empty buffers for each geometry type for buffer_type in ["_points_data", "_lines_data", "_frontfaces_data", "_backfaces_data"]: self.positions[buffer_type] = np.array([], dtype=np.float32) @@ -106,31 +109,60 @@ def _add_buffer_data(self, obj: Any, buffer_type: str) -> None: """Add buffer data for a specific geometry type.""" positions, colors, elements = getattr(obj, buffer_type) - if len(colors) > len(positions): + # Handle numpy arrays from optimized path + is_numpy_positions = isinstance(positions, np.ndarray) + is_numpy_colors = isinstance(colors, np.ndarray) + + # Get number of vertices (handle both list and numpy) + n_positions = len(positions) if not is_numpy_positions else positions.shape[0] + n_colors = len(colors) if not is_numpy_colors else colors.shape[0] + + if n_colors > n_positions: print( - f"WARNING: Buffer type: {buffer_type} colors length: {len(colors)} greater than positions length: {len(positions)} for {obj}," + f"WARNING: Buffer type: {buffer_type} colors length: {n_colors} greater than positions length: {n_positions} for {obj}," "the remaining colors will be ignored" ) - colors = colors[: len(positions)] - elif len(colors) < len(positions): - print(f"WARNING: Buffer type: {buffer_type} colors length: {len(colors)} less than positions length: {len(positions)} for {obj}, last color will be repeated") - colors = colors + [colors[-1]] * (len(positions) - len(colors)) + colors = colors[:n_positions] + elif n_colors < n_positions: + if is_numpy_colors: + # Repeat last color row to match positions + last_color = colors[-1:] if len(colors.shape) > 1 else colors[-1] + padding = np.tile(last_color, (n_positions - n_colors, 1)) if len(colors.shape) > 1 else np.full(n_positions - n_colors, last_color) + colors = np.vstack([colors, padding]) if len(colors.shape) > 1 else np.append(colors, padding) + else: + print(f"WARNING: Buffer type: {buffer_type} colors length: {n_colors} less than positions length: {n_positions} for {obj}, last color will be repeated") + colors = colors + [colors[-1]] * (n_positions - n_colors) + + # Convert to numpy arrays (skip if already numpy) + if is_numpy_positions: + pos_array = positions.astype(np.float32).flatten() + else: + pos_array = np.array(positions, dtype=np.float32).flatten() - # Convert to numpy arrays - pos_array = np.array(positions, dtype=np.float32).flatten() - col_array = np.array([c.rgba for c in colors] if len(colors) > 0 and isinstance(colors[0], Color) else colors, dtype=np.float32).flatten() - elem_array = np.array(elements, dtype=np.int32).flatten() + if is_numpy_colors: + col_array = colors.astype(np.float32).flatten() + else: + col_array = np.array([c.rgba for c in colors] if len(colors) > 0 and isinstance(colors[0], Color) else colors, dtype=np.float32).flatten() + + if isinstance(elements, np.ndarray): + elem_array = elements.astype(np.int32).flatten() + else: + elem_array = np.array(elements, dtype=np.int32).flatten() if buffer_type == "_frontfaces_data" or buffer_type == "_backfaces_data": opaque_elements = [] transparent_elements = [] for e in elem_array: - if e >= len(colors): + if e >= n_colors: # print("WARNING: Element index out of range", obj) # TODO: Fix BREP from IFC continue color = colors[e] - alpha = color.a if isinstance(color, Color) else color[3] + if is_numpy_colors: + # Numpy array: access 4th element (alpha) directly + alpha = color[3] if len(color) > 3 else 1.0 + else: + alpha = color.a if isinstance(color, Color) else color[3] if alpha < 1.0 or obj.opacity < 1.0: transparent_elements.append(e) else: @@ -142,7 +174,7 @@ def _add_buffer_data(self, obj: Any, buffer_type: str) -> None: # Create vertex indices object_index = len(self.transforms) - obj_indices = np.full(len(positions), object_index, dtype=np.float32) + obj_indices = np.full(n_positions, object_index, dtype=np.float32) # Append to existing buffers self.positions[buffer_type] = np.append(self.positions[buffer_type], pos_array) @@ -269,6 +301,7 @@ def clear(self) -> None: self.transforms = [] self.settings = [] self.object_settings_cache = {} + self._dirty_settings.clear() def update_object_transform(self, obj: Any) -> None: """Update the transformation matrix for a single object. @@ -338,10 +371,28 @@ def update_object_data(self, obj: Any) -> None: col_byte_offset = start_idx * 4 * 4 # 4 floats per color * 4 bytes per float update_vertex_buffer(col_array, self.buffer_ids[data_type]["colors"], offset=col_byte_offset) + def mark_settings_dirty(self, obj: Any) -> None: + """Mark an object's settings as needing GPU update. + + Parameters + ---------- + obj : Any + The object whose settings should be marked dirty. + """ + self._dirty_settings.add(obj) + + def mark_all_settings_dirty(self) -> None: + """Mark all objects' settings as needing GPU update.""" + self._dirty_settings.update(self.objects.keys()) + def update_settings(self): - """Update the settings for all objects.""" - for obj in self.objects: - self.update_object_settings(obj) + """Update the settings for dirty objects only.""" + if not self._dirty_settings: + return + for obj in self._dirty_settings: + if obj in self.objects: + self.update_object_settings(obj) + self._dirty_settings.clear() def update_object_settings(self, obj: Any) -> None: """Update the settings for a single object.""" diff --git a/src/compas_viewer/scene/meshobject.py b/src/compas_viewer/scene/meshobject.py index 00b834b4db..6c21c895e3 100644 --- a/src/compas_viewer/scene/meshobject.py +++ b/src/compas_viewer/scene/meshobject.py @@ -3,6 +3,8 @@ from typing import Optional from typing import Union +import numpy as np + from compas.colors import Color from compas.datastructures import Mesh from compas.geometry import centroid_points @@ -102,7 +104,9 @@ def show_points(self) -> bool: @show_points.setter def show_points(self, show: bool): - self.show_vertices = show + if not hasattr(self, 'show_vertices') or self.show_vertices != show: + self.show_vertices = show + self._mark_settings_dirty() @property def show_lines(self) -> bool: @@ -110,7 +114,9 @@ def show_lines(self) -> bool: @show_lines.setter def show_lines(self, show: bool): - self.show_edges = show + if not hasattr(self, 'show_edges') or self.show_edges != show: + self.show_edges = show + self._mark_settings_dirty() @property def pointsize(self) -> float: @@ -118,7 +124,9 @@ def pointsize(self) -> float: @pointsize.setter def pointsize(self, size: float): - self.vertexsize = size + if not hasattr(self, 'vertexsize') or self.vertexsize != size: + self.vertexsize = size + self._mark_settings_dirty() @property def linewidth(self) -> float: @@ -126,7 +134,93 @@ def linewidth(self) -> float: @linewidth.setter def linewidth(self, size: float): - self.edgesize = size + if not hasattr(self, 'edgesize') or self.edgesize != size: + self.edgesize = size + self._mark_settings_dirty() + + def _is_triangulated(self) -> bool: + """Check if mesh contains only triangular faces.""" + for face in self.mesh.faces(): + if len(self.mesh.face_vertices(face)) != 3: + return False + return True + + def _read_frontfaces_data_numpy(self) -> ShaderDataType: + """Numpy-optimized face data reading for triangulated meshes.""" + # Get vertex coordinates as numpy array + vertices = np.array([self.mesh.vertex_coordinates(v) for v in self.mesh.vertices()], dtype=np.float32) + + # Build vertex index mapping for efficient lookup + vertex_list = list(self.mesh.vertices()) + vertex_to_idx = {v: i for i, v in enumerate(vertex_list)} + + # Get face vertex indices + faces = list(self.mesh.faces()) + face_indices = np.array( + [[vertex_to_idx[v] for v in self.mesh.face_vertices(f)] for f in faces], + dtype=np.int32 + ) + + # Gather positions: (n_faces, 3 vertices, 3 coords) -> flatten to (n_verts, 3) + positions = vertices[face_indices].reshape(-1, 3) + + # Handle colors - build numpy array directly + n_face_verts = len(faces) * 3 + if self.use_vertexcolors: + # Per-vertex colors + colors_list = [] + for f in faces: + verts = self.mesh.face_vertices(f) + for v in verts: + colors_list.append(self.vertexcolor[v].rgba) + colors = np.array(colors_list, dtype=np.float32) + else: + # Per-face colors - use numpy broadcasting + face_colors = np.array([self.facecolor[f].rgba for f in faces], dtype=np.float32) + colors = np.repeat(face_colors, 3, axis=0) + + # Elements: sequential triangle indices as numpy + n_triangles = len(faces) + elements = np.arange(n_triangles * 3, dtype=np.int32).reshape(-1, 3) + + return positions, colors, elements + + def _read_backfaces_data_numpy(self) -> ShaderDataType: + """Numpy-optimized backface data reading for triangulated meshes (reversed winding).""" + # Get vertex coordinates as numpy array + vertices = np.array([self.mesh.vertex_coordinates(v) for v in self.mesh.vertices()], dtype=np.float32) + + # Build vertex index mapping + vertex_list = list(self.mesh.vertices()) + vertex_to_idx = {v: i for i, v in enumerate(vertex_list)} + + # Get face vertex indices (reversed for backfaces) + faces = list(self.mesh.faces()) + face_indices = np.array( + [[vertex_to_idx[v] for v in self.mesh.face_vertices(f)[::-1]] for f in faces], + dtype=np.int32 + ) + + # Gather positions + positions = vertices[face_indices].reshape(-1, 3) + + # Handle colors - build numpy array directly + if self.use_vertexcolors: + colors_list = [] + for f in faces: + verts = self.mesh.face_vertices(f)[::-1] + for v in verts: + colors_list.append(self.vertexcolor[v].rgba) + colors = np.array(colors_list, dtype=np.float32) + else: + face_colors = np.array([self.facecolor[f].rgba for f in faces], dtype=np.float32) + colors = np.repeat(face_colors, 3, axis=0) + + # Elements: sequential triangle indices as numpy + n_triangles = len(faces) + elements = np.arange(n_triangles * 3, dtype=np.int32).reshape(-1, 3) + + return positions, colors, elements def _read_points_data(self) -> ShaderDataType: positions = [] @@ -169,6 +263,10 @@ def _read_lines_data(self) -> ShaderDataType: return positions, colors, elements def _read_frontfaces_data(self) -> ShaderDataType: + # Use numpy fast path for triangulated meshes + if self._is_triangulated(): + return self._read_frontfaces_data_numpy() + positions = [] colors = [] elements = [] @@ -238,6 +336,10 @@ def _read_frontfaces_data(self) -> ShaderDataType: return positions, colors, elements def _read_backfaces_data(self) -> ShaderDataType: + # Use numpy fast path for triangulated meshes + if self._is_triangulated(): + return self._read_backfaces_data_numpy() + positions = [] colors = [] elements = [] diff --git a/src/compas_viewer/scene/sceneobject.py b/src/compas_viewer/scene/sceneobject.py index 1e117c102e..d6f605a9ce 100644 --- a/src/compas_viewer/scene/sceneobject.py +++ b/src/compas_viewer/scene/sceneobject.py @@ -85,18 +85,27 @@ def __init__( use_rgba: bool = False, **kwargs, ): - # Basic + # Initialize private attributes BEFORE super().__init__() because + # base classes may set properties that trigger our setters + self._inited = False + self._show = show + self._show_points = show_points if show_points is not None else False + self._show_lines = show_lines if show_lines is not None else True + self._show_faces = show_faces if show_faces is not None else True + self._linewidth = linewidth + self._pointsize = pointsize + self._opacity = opacity + self._is_selected = is_selected + super().__init__(**kwargs) - self.show = show - self.show_points = show_points if show_points is not None else False - self.show_lines = show_lines if show_lines is not None else True - self.show_faces = show_faces if show_faces is not None else True - self.linewidth = linewidth if linewidth is not None else self.viewer.config.ui.display.linewidth - self.pointsize = pointsize if pointsize is not None else self.viewer.config.ui.display.pointsize - self.opacity = opacity if opacity is not None else self.viewer.config.ui.display.opacity - # Selection - self.is_selected = is_selected + # Apply defaults after super().__init__() when self.viewer is available + if self._linewidth is None: + self._linewidth = self.viewer.config.ui.display.linewidth + if self._pointsize is None: + self._pointsize = self.viewer.config.ui.display.pointsize + if self._opacity is None: + self._opacity = self.viewer.config.ui.display.opacity # Visual self.background: bool = False @@ -112,9 +121,93 @@ def __init__( self._frontfaces_data: Optional[ShaderDataType] = None self._backfaces_data: Optional[ShaderDataType] = None - self._inited = False self.context = "Viewer" + def _mark_settings_dirty(self): + """Mark this object's settings as needing GPU update.""" + if self._inited: + self.buffer_manager.mark_settings_dirty(self) + + @property + def show(self) -> bool: + return self._show + + @show.setter + def show(self, value: bool): + if self._show != value: + self._show = value + self._mark_settings_dirty() + + @property + def show_points(self) -> bool: + return self._show_points + + @show_points.setter + def show_points(self, value: bool): + if self._show_points != value: + self._show_points = value + self._mark_settings_dirty() + + @property + def show_lines(self) -> bool: + return self._show_lines + + @show_lines.setter + def show_lines(self, value: bool): + if self._show_lines != value: + self._show_lines = value + self._mark_settings_dirty() + + @property + def show_faces(self) -> bool: + return self._show_faces + + @show_faces.setter + def show_faces(self, value: bool): + if self._show_faces != value: + self._show_faces = value + self._mark_settings_dirty() + + @property + def linewidth(self) -> float: + return self._linewidth + + @linewidth.setter + def linewidth(self, value: float): + if self._linewidth != value: + self._linewidth = value + self._mark_settings_dirty() + + @property + def pointsize(self) -> float: + return self._pointsize + + @pointsize.setter + def pointsize(self, value: float): + if self._pointsize != value: + self._pointsize = value + self._mark_settings_dirty() + + @property + def opacity(self) -> float: + return self._opacity + + @opacity.setter + def opacity(self, value: float): + if self._opacity != value: + self._opacity = value + self._mark_settings_dirty() + + @property + def is_selected(self) -> bool: + return self._is_selected + + @is_selected.setter + def is_selected(self, value: bool): + if self._is_selected != value: + self._is_selected = value + self._mark_settings_dirty() + @property def bounding_box(self): return self._bounding_box @@ -156,6 +249,7 @@ def init(self): self._update_bounding_box() self.instance_color = Color.from_rgb255(*next(self.viewer.scene._instance_colors_generator)) self.viewer.scene.instance_colors[self.instance_color.rgb255] = self + self._inited = True def update(self, update_transform: bool = True, update_data: bool = False): """Update the object.