feat: Replace SDF kernel with OpenCASCADE, VTK with pygfx

Major architecture migration:

- Remove SDF-based geometry kernel (sdf/)
- Remove VTK renderer (drawing_modules/)
- Remove old mesh modules (mesh_modules/)

New components:
- geometry/base.py: Abstract geometry kernel interface
- geometry_occ/kernel.py: OpenCASCADE implementation via CadQuery/OCP
- geometry_occ/sketch.py: 2D sketching with constraint solving
- rendering/base.py: Abstract renderer interface
- rendering/pygfx_renderer.py: WebGPU-based renderer
- models/data_model.py: Project, Component, Sketch, Body classes
- main.py: New Qt-based application

Features:
- STEP/IGES import/export
- Exact BRep geometry (vs approximate SDF mesh)
- Parametric sketching with constraints
- Boolean operations (union, difference, intersection)
- Fillet and chamfer operations
- Modern pygfx renderer (~30MB vs VTK ~200MB)

Dependencies:
- cadquery >= 2.4
- ocp >= 7.9.3
- pygfx >= 0.7.0
- wgpu >= 0.19.0
- PySide6 >= 6.9.0
This commit is contained in:
bklronin
2026-03-14 08:45:07 +01:00
parent d6044e551a
commit fe23ca610c
90 changed files with 3737 additions and 14523 deletions
+16
View File
@@ -0,0 +1,16 @@
"""Rendering module."""
from fluency.rendering.base import (
Renderer,
RenderObject,
RenderColor,
)
from fluency.rendering.pygfx_renderer import PygfxRenderer, PygfxRenderObject
__all__ = [
"Renderer",
"RenderObject",
"RenderColor",
"PygfxRenderer",
"PygfxRenderObject",
]
+380
View File
@@ -0,0 +1,380 @@
"""
Rendering abstraction layer for Fluency CAD.
This module defines abstract interfaces for 3D rendering,
allowing different rendering backends to be used interchangeably.
"""
from abc import ABC, abstractmethod
from typing import List, Tuple, Optional, Callable, Any
from dataclasses import dataclass
import numpy as np
@dataclass
class RenderColor:
"""RGB color representation."""
r: float
g: float
b: float
a: float = 1.0
def to_tuple(self) -> Tuple[float, float, float, float]:
return (self.r, self.g, self.b, self.a)
def to_tuple_rgb(self) -> Tuple[float, float, float]:
return (self.r, self.g, self.b)
@classmethod
def from_hex(cls, hex_color: str) -> "RenderColor":
"""Create color from hex string (#RRGGBB or #RRGGBBAA)."""
hex_color = hex_color.lstrip("#")
if len(hex_color) == 6:
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
return cls(r, g, b)
elif len(hex_color) == 8:
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
a = int(hex_color[6:8], 16) / 255.0
return cls(r, g, b, a)
raise ValueError(f"Invalid hex color: {hex_color}")
class RenderObject:
"""Base class for renderable objects."""
def __init__(self, name: Optional[str] = None):
self.name = name
self.visible: bool = True
self.selected: bool = False
self.color: RenderColor = RenderColor(0.2, 0.4, 0.8)
self._scene_node: Any = None
def set_color(self, color: RenderColor) -> None:
self.color = color
def set_visible(self, visible: bool) -> None:
self.visible = visible
def set_selected(self, selected: bool) -> None:
self.selected = selected
class Renderer(ABC):
"""
Abstract base class for 3D renderers.
A renderer provides 3D visualization capabilities including
mesh display, camera control, and object selection.
"""
@abstractmethod
def initialize(self, parent_widget: Any) -> bool:
"""
Initialize the renderer with a parent widget.
Args:
parent_widget: Qt widget to embed the renderer in
Returns:
True if initialization succeeded
"""
pass
@abstractmethod
def shutdown(self) -> None:
"""Clean up renderer resources."""
pass
@abstractmethod
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,
) -> RenderObject:
"""
Add a mesh to the scene.
Args:
vertices: Nx3 array of vertex positions
faces: Mx3 array of triangle indices
color: RGB color tuple
name: Optional name for the object
Returns:
RenderObject representing the mesh
"""
pass
@abstractmethod
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,
) -> RenderObject:
"""
Add a wireframe to the scene.
Args:
vertices: Nx3 array of vertex positions
edges: Mx2 array of edge vertex indices
color: RGB color tuple
line_width: Width of lines
name: Optional name for the object
Returns:
RenderObject representing the wireframe
"""
pass
@abstractmethod
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,
) -> RenderObject:
"""
Add points to the scene.
Args:
points: Nx3 array of point positions
color: RGB color tuple
size: Point size
name: Optional name for the object
Returns:
RenderObject representing the points
"""
pass
@abstractmethod
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,
) -> RenderObject:
"""
Add line segments to the scene.
Args:
start_points: Nx3 array of line start positions
end_points: Nx3 array of line end positions
color: RGB color tuple
line_width: Width of lines
name: Optional name for the object
Returns:
RenderObject representing the lines
"""
pass
@abstractmethod
def remove_object(self, obj: RenderObject) -> bool:
"""
Remove an object from the scene.
Args:
obj: Object to remove
Returns:
True if removal succeeded
"""
pass
@abstractmethod
def clear_scene(self) -> None:
"""Remove all objects from the scene."""
pass
@abstractmethod
def update_mesh(self, obj: RenderObject, vertices: np.ndarray, faces: np.ndarray) -> bool:
"""
Update mesh geometry.
Args:
obj: Object to update
vertices: New Nx3 array of vertex positions
faces: New Mx3 array of triangle indices
Returns:
True if update succeeded
"""
pass
@abstractmethod
def set_object_color(self, obj: RenderObject, color: Tuple[float, float, float]) -> None:
"""Set the color of an object."""
pass
@abstractmethod
def set_object_visible(self, obj: RenderObject, visible: bool) -> None:
"""Set the visibility of an object."""
pass
@abstractmethod
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.
Args:
position: Camera position
target: Point camera is looking at
up: Up vector
"""
pass
@abstractmethod
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Get camera position, target, and up vector.
Returns:
Tuple of (position, target, up) as numpy arrays
"""
pass
@abstractmethod
def fit_camera(self, padding: float = 1.1) -> None:
"""
Fit camera to show all objects.
Args:
padding: Padding factor (1.0 = exact fit)
"""
pass
@abstractmethod
def set_camera_perspective(
self, fov: float = 50.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set camera perspective parameters."""
pass
@abstractmethod
def set_camera_orthographic(
self, width: float = 100.0, near: float = 0.1, far: float = 10000.0
) -> None:
"""Set camera orthographic parameters."""
pass
@abstractmethod
def render(self) -> None:
"""Trigger a render."""
pass
@abstractmethod
def on_pick(self, callback: Callable[[Any], None]) -> None:
"""
Register a callback for picking/selection.
Args:
callback: Function called with pick info when object is clicked
"""
pass
@abstractmethod
def on_camera_change(self, callback: Callable[[], None]) -> None:
"""
Register a callback for camera changes.
Args:
callback: Function called when camera moves
"""
pass
@abstractmethod
def set_background_color(self, color: Tuple[float, float, float]) -> None:
"""Set the background color."""
pass
@abstractmethod
def add_grid(
self,
size: float = 100.0,
divisions: int = 10,
color: Tuple[float, float, float] = (0.3, 0.3, 0.3),
) -> RenderObject:
"""Add a reference grid."""
pass
@abstractmethod
def add_axes(self, size: float = 10.0, visible: bool = True) -> RenderObject:
"""Add coordinate axes."""
pass
@abstractmethod
def get_screen_size(self) -> Tuple[int, int]:
"""Get the screen size in pixels."""
pass
@abstractmethod
def project_to_screen(self, point: Tuple[float, float, float]) -> Tuple[int, int]:
"""
Project a 3D point to screen coordinates.
Args:
point: 3D point to project
Returns:
Screen (x, y) coordinates
"""
pass
@abstractmethod
def unproject_from_screen(
self, screen_x: int, screen_y: int, depth: float = 0.0
) -> Tuple[float, float, float]:
"""
Unproject screen coordinates to 3D.
Args:
screen_x: Screen x coordinate
screen_y: Screen y coordinate
depth: Depth value (0=near, 1=far)
Returns:
3D point coordinates
"""
pass
@abstractmethod
def take_screenshot(self) -> np.ndarray:
"""
Take a screenshot of the current view.
Returns:
RGBA image as numpy array
"""
pass
@abstractmethod
def save_screenshot(self, filepath: str) -> bool:
"""
Save a screenshot to file.
Args:
filepath: Path to save screenshot
Returns:
True if save succeeded
"""
pass
+411
View File
@@ -0,0 +1,411 @@
"""
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,
)
@dataclass
class PygfxRenderObject(RenderObject):
"""pygfx render object wrapper."""
scene_node: Any = None
geometry: Any = None
material: Any = None
def __post_init__(self) -> None:
if self.scene_node is not None:
self._scene_node = self.scene_node
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 wgpu.gui.qt import WgpuCanvas
from PySide6.QtWidgets import QVBoxLayout
self._canvas = WgpuCanvas(parent=parent_widget)
self._renderer = gfx.renderers.WgpuRenderer(self._canvas)
self._scene = gfx.Scene()
self._camera = gfx.PerspectiveCamera(50, 16 / 9)
self._camera.position.set(100, 100, 100)
self._controller = gfx.OrbitController(self._camera, 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._setup_picking()
self._initialized = True
return True
except Exception as e:
print(f"Failed to initialize pygfx: {e}")
return False
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.position.set(100, 100, 100)
self._scene.add(directional)
fill = gfx.DirectionalLight(intensity=0.5)
fill.position.set(-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,
) -> PygfxRenderObject:
"""Add a mesh to the scene."""
import pygfx as gfx
vertices = np.asarray(vertices, dtype=np.float32)
faces = np.asarray(faces, dtype=np.int32)
geometry = gfx.Geometry(positions=vertices, indices=faces)
normals = gfx.compute_normals(geometry)
geometry.normals = normals
material = gfx.MeshPhongMaterial(color=color, flat_shading=False)
mesh = gfx.Mesh(geometry, material)
self._scene.add(mesh)
obj = PygfxRenderObject(name=name, scene_node=mesh, geometry=geometry, material=material)
obj.color = RenderColor(*color)
self._objects.append(obj)
return obj
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,
) -> PygfxRenderObject:
"""Add a wireframe to the scene."""
import pygfx as gfx
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)
obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
obj.color = RenderColor(*color)
self._objects.append(obj)
return obj
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,
) -> PygfxRenderObject:
"""Add points to the scene."""
import pygfx as gfx
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)
obj = PygfxRenderObject(
name=name, scene_node=points_obj, geometry=geometry, material=material
)
obj.color = RenderColor(*color)
self._objects.append(obj)
return obj
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,
) -> PygfxRenderObject:
"""Add line segments to the scene."""
import pygfx as gfx
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)
obj = PygfxRenderObject(name=name, scene_node=lines, geometry=geometry, material=material)
obj.color = RenderColor(*color)
self._objects.append(obj)
return obj
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 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)
geometry = gfx.Geometry(positions=vertices, indices=faces)
geometry.normals = gfx.compute_normals(geometry)
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.position.set(*position)
self._camera.look_at(*target)
self._camera.up.set(*up)
def get_camera_position(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Get camera position, target, and up vector."""
pos = np.array(self._camera.position.to_array())
target = np.array([0, 0, 0])
up = np.array(self._camera.up.to_array())
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.position.set(center[0] + size, center[1] + size, center[2] + size)
self._camera.look_at(*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._renderer.render(self._scene, self._camera)
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),
) -> PygfxRenderObject:
"""Add a reference grid."""
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)
return obj
def add_axes(self, size: float = 10.0, visible: bool = True) -> PygfxRenderObject:
"""Add coordinate axes."""
import pygfx as gfx
axes = gfx.AxesHelper(size=size)
axes.visible = visible
self._scene.add(axes)
obj = PygfxRenderObject(name="axes", scene_node=axes)
return obj
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