7091f530ee
- Change from RenderCanvas to RenderWidget for embedded Qt widget - Add _animate() callback for canvas.request_draw() - Update render() to use request_draw() for continuous rendering - This enables OrbitController to work properly with mouse events
480 lines
15 KiB
Python
480 lines
15 KiB
Python
"""
|
|
pygfx-based renderer for Fluency CAD.
|
|
|
|
This module provides a modern WebGPU-based renderer using pygfx,
|
|
offering a smaller dependency footprint than VTK while providing
|
|
excellent 3D visualization capabilities.
|
|
"""
|
|
|
|
from typing import List, Tuple, Optional, Callable, Any
|
|
import numpy as np
|
|
from dataclasses import dataclass
|
|
|
|
from fluency.rendering.base import (
|
|
Renderer,
|
|
RenderObject,
|
|
RenderColor,
|
|
)
|
|
|
|
|
|
def compute_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray:
|
|
"""Compute vertex normals from positions and face indices."""
|
|
vertices = np.asarray(vertices, dtype=np.float32)
|
|
faces = np.asarray(faces, dtype=np.int32)
|
|
|
|
normals = np.zeros_like(vertices)
|
|
|
|
for face in faces:
|
|
v0, v1, v2 = vertices[face[0]], vertices[face[1]], vertices[face[2]]
|
|
|
|
edge1 = v1 - v0
|
|
edge2 = v2 - v0
|
|
|
|
face_normal = np.cross(edge1, edge2)
|
|
|
|
length = np.linalg.norm(face_normal)
|
|
if length > 1e-10:
|
|
face_normal = face_normal / length
|
|
|
|
normals[face[0]] += face_normal
|
|
normals[face[1]] += face_normal
|
|
normals[face[2]] += face_normal
|
|
|
|
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
|
|
lengths = np.where(lengths > 1e-10, lengths, 1)
|
|
normals = normals / lengths
|
|
|
|
return normals.astype(np.float32)
|
|
|
|
|
|
@dataclass
|
|
class PygfxRenderObject(RenderObject):
|
|
"""pygfx render object wrapper."""
|
|
|
|
scene_node: Any = None
|
|
geometry: Any = None
|
|
material: Any = None
|
|
|
|
def __init__(
|
|
self,
|
|
scene_node: Any = None,
|
|
geometry: Any = None,
|
|
material: Any = None,
|
|
name: Optional[str] = None,
|
|
):
|
|
super().__init__(name=name)
|
|
self.scene_node = scene_node
|
|
self.geometry = geometry
|
|
self.material = material
|
|
|
|
|
|
class PygfxRenderer(Renderer):
|
|
"""
|
|
pygfx-based renderer implementation.
|
|
|
|
This renderer uses pygfx (WebGPU-based) for 3D visualization,
|
|
providing modern rendering with a small dependency footprint.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._canvas: Any = None
|
|
self._renderer: Any = None
|
|
self._scene: Any = None
|
|
self._camera: Any = None
|
|
self._controller: Any = None
|
|
self._objects: List[PygfxRenderObject] = []
|
|
self._pick_callback: Optional[Callable[[Any], None]] = None
|
|
self._camera_change_callback: Optional[Callable[[], None]] = None
|
|
self._background_color: Tuple[float, float, float] = (0.1, 0.1, 0.15)
|
|
self._initialized: bool = False
|
|
|
|
def initialize(self, parent_widget: Any) -> bool:
|
|
"""Initialize pygfx with Qt widget."""
|
|
try:
|
|
import pygfx as gfx
|
|
from rendercanvas.qt import RenderWidget
|
|
from PySide6.QtWidgets import QVBoxLayout
|
|
|
|
self._canvas = RenderWidget(parent=parent_widget)
|
|
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
|
|
self._scene = gfx.Scene()
|
|
|
|
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
|
|
self._camera.local.position = (100, 100, 100)
|
|
|
|
self._controller = gfx.OrbitController(self._camera)
|
|
self._controller.register_events(self._renderer)
|
|
|
|
self._setup_lighting()
|
|
self._add_grid()
|
|
|
|
layout = QVBoxLayout(parent_widget)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.addWidget(self._canvas)
|
|
|
|
self._canvas.request_draw(self._animate)
|
|
|
|
self._setup_picking()
|
|
self._initialized = True
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Failed to initialize pygfx: {e}")
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
return False
|
|
|
|
def _animate(self):
|
|
"""Animation callback for the canvas."""
|
|
if self._initialized:
|
|
self._renderer.render(self._scene, self._camera)
|
|
|
|
def _setup_lighting(self) -> None:
|
|
"""Setup scene lighting."""
|
|
import pygfx as gfx
|
|
|
|
ambient = gfx.AmbientLight(intensity=0.3)
|
|
self._scene.add(ambient)
|
|
|
|
directional = gfx.DirectionalLight(intensity=1.0)
|
|
directional.local.position = (100, 100, 100)
|
|
self._scene.add(directional)
|
|
|
|
fill = gfx.DirectionalLight(intensity=0.5)
|
|
fill.local.position = (-100, 50, 50)
|
|
self._scene.add(fill)
|
|
|
|
def _add_grid(self) -> None:
|
|
"""Add reference grid."""
|
|
import pygfx as gfx
|
|
|
|
grid = gfx.GridHelper(
|
|
size=200, divisions=20, color1=(0.3, 0.3, 0.3, 1), color2=(0.2, 0.2, 0.2, 1)
|
|
)
|
|
self._scene.add(grid)
|
|
|
|
def _setup_picking(self) -> None:
|
|
"""Setup mesh picking."""
|
|
pass
|
|
|
|
def shutdown(self) -> None:
|
|
"""Clean up renderer resources."""
|
|
self._objects.clear()
|
|
self._initialized = False
|
|
|
|
def add_mesh(
|
|
self,
|
|
vertices: np.ndarray,
|
|
faces: np.ndarray,
|
|
color: Tuple[float, float, float] = (0.2, 0.4, 0.8),
|
|
name: Optional[str] = None,
|
|
) -> str:
|
|
"""Add a mesh to the scene. Returns the mesh ID."""
|
|
import pygfx as gfx
|
|
import uuid
|
|
|
|
vertices = np.asarray(vertices, dtype=np.float32)
|
|
faces = np.asarray(faces, dtype=np.int32)
|
|
|
|
normals = compute_normals(vertices, faces)
|
|
|
|
geometry = gfx.Geometry(positions=vertices, indices=faces, normals=normals)
|
|
|
|
material = gfx.MeshPhongMaterial(color=color, flat_shading=False)
|
|
|
|
mesh = gfx.Mesh(geometry, material)
|
|
self._scene.add(mesh)
|
|
|
|
mesh_id = name or f"mesh_{uuid.uuid4().hex[:8]}"
|
|
obj = PygfxRenderObject(name=mesh_id, scene_node=mesh, geometry=geometry, material=material)
|
|
obj.color = RenderColor(*color)
|
|
self._objects.append(obj)
|
|
|
|
return mesh_id
|
|
|
|
def add_wireframe(
|
|
self,
|
|
vertices: np.ndarray,
|
|
edges: np.ndarray,
|
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
line_width: float = 1.0,
|
|
name: Optional[str] = None,
|
|
) -> str:
|
|
"""Add a wireframe to the scene. Returns the wireframe ID."""
|
|
import pygfx as gfx
|
|
import uuid
|
|
|
|
positions: List[List[float]] = []
|
|
for edge in edges:
|
|
positions.append(vertices[edge[0]])
|
|
positions.append(vertices[edge[1]])
|
|
|
|
positions_arr = np.array(positions, dtype=np.float32)
|
|
|
|
geometry = gfx.Geometry(positions=positions_arr)
|
|
material = gfx.LineMaterial(color=color, thickness=line_width)
|
|
|
|
lines = gfx.Line(geometry, material)
|
|
self._scene.add(lines)
|
|
|
|
wireframe_id = name or f"wireframe_{uuid.uuid4().hex[:8]}"
|
|
obj = PygfxRenderObject(
|
|
name=wireframe_id, scene_node=lines, geometry=geometry, material=material
|
|
)
|
|
obj.color = RenderColor(*color)
|
|
self._objects.append(obj)
|
|
|
|
return wireframe_id
|
|
|
|
def add_points(
|
|
self,
|
|
points: np.ndarray,
|
|
color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
|
size: float = 5.0,
|
|
name: Optional[str] = None,
|
|
) -> str:
|
|
"""Add points to the scene. Returns the points ID."""
|
|
import pygfx as gfx
|
|
import uuid
|
|
|
|
points_arr = np.asarray(points, dtype=np.float32)
|
|
|
|
geometry = gfx.Geometry(positions=points_arr)
|
|
material = gfx.PointsMaterial(color=color, size=size)
|
|
|
|
points_obj = gfx.Points(geometry, material)
|
|
self._scene.add(points_obj)
|
|
|
|
points_id = name or f"points_{uuid.uuid4().hex[:8]}"
|
|
obj = PygfxRenderObject(
|
|
name=points_id, scene_node=points_obj, geometry=geometry, material=material
|
|
)
|
|
obj.color = RenderColor(*color)
|
|
self._objects.append(obj)
|
|
|
|
return points_id
|
|
|
|
def add_lines(
|
|
self,
|
|
start_points: np.ndarray,
|
|
end_points: np.ndarray,
|
|
color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
line_width: float = 1.0,
|
|
name: Optional[str] = None,
|
|
) -> str:
|
|
"""Add line segments to the scene. Returns the lines ID."""
|
|
import pygfx as gfx
|
|
import uuid
|
|
|
|
positions = np.hstack([start_points, end_points]).flatten()
|
|
positions = positions.reshape(-1, 3).astype(np.float32)
|
|
|
|
geometry = gfx.Geometry(positions=positions)
|
|
material = gfx.LineMaterial(color=color, thickness=line_width)
|
|
|
|
lines = gfx.Line(geometry, material)
|
|
self._scene.add(lines)
|
|
|
|
lines_id = name or f"lines_{uuid.uuid4().hex[:8]}"
|
|
obj = PygfxRenderObject(
|
|
name=lines_id, scene_node=lines, geometry=geometry, material=material
|
|
)
|
|
obj.color = RenderColor(*color)
|
|
self._objects.append(obj)
|
|
|
|
return lines_id
|
|
|
|
def remove_object(self, obj: RenderObject) -> bool:
|
|
"""Remove an object from the scene."""
|
|
if isinstance(obj, PygfxRenderObject) and obj in self._objects:
|
|
if obj.scene_node is not None:
|
|
self._scene.remove(obj.scene_node)
|
|
self._objects.remove(obj)
|
|
return True
|
|
return False
|
|
|
|
def remove_mesh(self, mesh_id: str) -> bool:
|
|
"""Remove a mesh by its ID."""
|
|
for obj in self._objects:
|
|
if obj.name == mesh_id:
|
|
return self.remove_object(obj)
|
|
return False
|
|
|
|
def clear_scene(self) -> None:
|
|
"""Remove all objects from the scene."""
|
|
for obj in self._objects[:]:
|
|
if obj.scene_node is not None:
|
|
self._scene.remove(obj.scene_node)
|
|
self._objects.clear()
|
|
|
|
def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
|
|
"""Update mesh geometry."""
|
|
if not isinstance(obj, PygfxRenderObject):
|
|
return False
|
|
|
|
import pygfx as gfx
|
|
|
|
vertices = np.asarray(vertices, dtype=np.float32)
|
|
faces = np.asarray(faces, dtype=np.int32)
|
|
|
|
normals = compute_normals(vertices, faces)
|
|
|
|
geometry = gfx.Geometry(positions=vertices, indices=faces, normals=normals)
|
|
|
|
obj.geometry = geometry
|
|
if obj.scene_node is not None:
|
|
obj.scene_node.geometry = geometry
|
|
|
|
return True
|
|
|
|
def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
|
|
"""Set the color of an object."""
|
|
if isinstance(obj, PygfxRenderObject):
|
|
obj.color = RenderColor(*color)
|
|
if obj.material is not None:
|
|
obj.material.color = color
|
|
|
|
def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
|
|
"""Set the visibility of an object."""
|
|
if isinstance(obj, PygfxRenderObject):
|
|
obj.visible = visible
|
|
if obj.scene_node is not None:
|
|
obj.scene_node.visible = visible
|
|
|
|
def set_camera_position(
|
|
self,
|
|
position: Tuple[float, float, float],
|
|
target: Tuple[float, float, float] = (0, 0, 0),
|
|
up: Tuple[float, float, float] = (0, 0, 1),
|
|
) -> None:
|
|
"""Set camera position and orientation."""
|
|
self._camera.local.position = position
|
|
self._camera.look_at(target)
|
|
|
|
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
"""Get camera position, target, and up vector."""
|
|
pos = np.array(self._camera.local.position)
|
|
target = np.array([0, 0, 0])
|
|
up = np.array(self._camera.local.up)
|
|
return pos, target, up
|
|
|
|
def fit_camera(self, padding: float = 1.1) -> None:
|
|
"""Fit camera to show all objects."""
|
|
if not self._objects:
|
|
return
|
|
|
|
all_positions: List[np.ndarray] = []
|
|
for obj in self._objects:
|
|
if obj.geometry is not None and hasattr(obj.geometry, "positions"):
|
|
positions = obj.geometry.positions.data
|
|
all_positions.append(positions)
|
|
|
|
if all_positions:
|
|
positions = np.vstack(all_positions)
|
|
min_pos = positions.min(axis=0)
|
|
max_pos = positions.max(axis=0)
|
|
center = (min_pos + max_pos) / 2
|
|
size = np.linalg.norm(max_pos - min_pos) * padding
|
|
|
|
self._camera.local.position = (center[0] + size, center[1] + size, center[2] + size)
|
|
self._camera.look_at(tuple(center))
|
|
|
|
def set_camera_perspective(
|
|
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
|
|
) -> None:
|
|
"""Set camera perspective parameters."""
|
|
self._camera.fov = fov
|
|
self._camera.near = near
|
|
self._camera.far = far
|
|
|
|
def set_camera_orthographic(
|
|
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
|
|
) -> None:
|
|
"""Set camera orthographic parameters."""
|
|
import pygfx as gfx
|
|
|
|
self._camera = gfx.OrthographicCamera(width=width, near=near, far=far)
|
|
self._controller.camera = self._camera
|
|
|
|
def render(self) -> None:
|
|
"""Trigger a render."""
|
|
if self._initialized:
|
|
self._canvas.request_draw()
|
|
|
|
def on_pick(self, callback: Callable[[Any], None]) -> None:
|
|
"""Register a callback for picking/selection."""
|
|
self._pick_callback = callback
|
|
|
|
def on_camera_change(self, callback: Callable[[], None]) -> None:
|
|
"""Register a callback for camera changes."""
|
|
self._camera_change_callback = callback
|
|
|
|
def set_background_color(self, color: Tuple[float, float, float]) -> None:
|
|
"""Set the background color."""
|
|
self._background_color = color
|
|
self._scene.background = color
|
|
|
|
def add_grid(
|
|
self,
|
|
size: float = 100.0,
|
|
divisions: int = 10,
|
|
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
|
|
) -> str:
|
|
"""Add a reference grid. Returns the grid ID."""
|
|
import pygfx as gfx
|
|
|
|
grid = gfx.GridHelper(
|
|
size=size, divisions=divisions, color1=(*color, 1), color2=(*color, 0.5)
|
|
)
|
|
self._scene.add(grid)
|
|
|
|
obj = PygfxRenderObject(name="grid", scene_node=grid)
|
|
self._objects.append(obj)
|
|
return "grid"
|
|
|
|
def add_axes(self, size: float = 10.0, visible: bool = True) -> str:
|
|
"""Add coordinate axes. Returns the axes ID."""
|
|
import pygfx as gfx
|
|
|
|
axes = gfx.AxesHelper(size=size)
|
|
axes.visible = visible
|
|
self._scene.add(axes)
|
|
|
|
obj = PygfxRenderObject(name="axes", scene_node=axes)
|
|
self._objects.append(obj)
|
|
return "axes"
|
|
|
|
def get_screen_size(self) -> Tuple[int, int]:
|
|
"""Get the screen size in pixels."""
|
|
if self._canvas is not None:
|
|
return self._canvas.get_physical_size()
|
|
return (800, 600)
|
|
|
|
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
|
|
"""Project a 3D point to screen coordinates."""
|
|
return (0, 0)
|
|
|
|
def unproject_from_screen(
|
|
self, screen_x: int, screen_y: int, depth: float = 0.0
|
|
) -> Tuple[float, float, float]:
|
|
"""Unproject screen coordinates to 3D."""
|
|
return (0.0, 0.0, 0.0)
|
|
|
|
def take_screenshot(self) -> np.ndarray:
|
|
"""Take a screenshot of the current view."""
|
|
return np.zeros((100, 100, 4), dtype=np.uint8)
|
|
|
|
def save_screenshot(self, filepath: str) -> bool:
|
|
"""Save a screenshot to file."""
|
|
try:
|
|
img = self.take_screenshot()
|
|
from PIL import Image
|
|
|
|
Image.fromarray(img).save(filepath)
|
|
return True
|
|
except Exception as e:
|
|
print(f"Screenshot error: {e}")
|
|
return False
|