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
+30
View File
@@ -0,0 +1,30 @@
"""
Fluency CAD - Parametric CAD Application
A modern parametric CAD application built on OpenCASCADE Technology (OCCT)
with a clean Python API using CadQuery.
"""
__version__ = "2.0.0"
__author__ = "Fluency CAD Team"
from fluency.geometry.base import (
Point2D,
Point3D,
GeometryObject,
GeometryKernel,
SketchInterface,
)
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch
__all__ = [
"Point2D",
"Point3D",
"GeometryObject",
"GeometryKernel",
"SketchInterface",
"OCGeometryKernel",
"OCCSketch",
]
+19
View File
@@ -0,0 +1,19 @@
"""Geometry abstraction layer for Fluency CAD."""
from fluency.geometry.base import (
Point2D,
Point3D,
GeometryObject,
GeometryKernel,
SketchInterface,
SketchEntity,
)
__all__ = [
"Point2D",
"Point3D",
"GeometryObject",
"GeometryKernel",
"SketchInterface",
"SketchEntity",
]
+437
View File
@@ -0,0 +1,437 @@
"""
Geometry abstraction layer for Fluency CAD.
This module defines abstract interfaces for geometry operations,
allowing different geometry kernels to be used interchangeably.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Tuple, Optional, Any, Dict
import numpy as np
@dataclass
class Point2D:
"""2D point representation."""
x: float
y: float
def to_tuple(self) -> Tuple[float, float]:
return (self.x, self.y)
def to_array(self) -> np.ndarray:
return np.array([self.x, self.y])
def distance_to(self, other: "Point2D") -> float:
return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point2D):
return False
return abs(self.x - other.x) < 1e-6 and abs(self.y - other.y) < 1e-6
@dataclass
class Point3D:
"""3D point representation."""
x: float
y: float
z: float
def to_tuple(self) -> Tuple[float, float, float]:
return (self.x, self.y, self.z)
def to_array(self) -> np.ndarray:
return np.array([self.x, self.y, self.z])
def distance_to(self, other: "Point3D") -> float:
return np.sqrt((self.x - other.x) ** 2 + (self.y - other.y) ** 2 + (self.z - other.z) ** 2)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Point3D):
return False
return (
abs(self.x - other.x) < 1e-6
and abs(self.y - other.y) < 1e-6
and abs(self.z - other.z) < 1e-6
)
class GeometryObject:
"""Base class for geometry objects."""
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
self.shape = shape
self.metadata = metadata or {}
self._mesh_cache: Optional[Tuple[np.ndarray, np.ndarray]] = None
def invalidate_cache(self) -> None:
"""Invalidate any cached data."""
self._mesh_cache = None
class SketchEntity:
"""Base class for sketch entities (points, lines, circles)."""
def __init__(self, entity_id: int, entity_type: str):
self.id = entity_id
self.entity_type = entity_type
self.constraints: List[str] = []
self.is_construction: bool = False
def add_constraint(self, constraint_type: str) -> None:
self.constraints.append(constraint_type)
class GeometryKernel(ABC):
"""
Abstract base class for geometry kernels.
A geometry kernel provides primitives, operations, and export capabilities
for CAD geometry.
"""
@abstractmethod
def create_point(self, x: float, y: float) -> GeometryObject:
"""Create a 2D point."""
pass
@abstractmethod
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
"""Create a 2D line segment."""
pass
@abstractmethod
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
"""Create a 2D circle."""
pass
@abstractmethod
def create_arc(
self, center: Point2D, radius: float, start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
pass
@abstractmethod
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
"""Create a closed polygon from points."""
pass
@abstractmethod
def create_rectangle(
self, width: float, height: float, center: Optional[Point2D] = None
) -> GeometryObject:
"""Create a rectangle."""
pass
@abstractmethod
def extrude(
self,
sketch: GeometryObject,
height: float,
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid."""
pass
@abstractmethod
def revolve(
self,
sketch: GeometryObject,
angle: float = 360.0,
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
pass
@abstractmethod
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
pass
@abstractmethod
def sweep(
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
) -> GeometryObject:
"""Sweep a profile along a path."""
pass
@abstractmethod
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
"""Union multiple bodies."""
pass
@abstractmethod
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
"""Subtract tool from base."""
pass
@abstractmethod
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
"""Intersect two bodies."""
pass
@abstractmethod
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
pass
@abstractmethod
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
pass
@abstractmethod
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
) -> GeometryObject:
"""Create a shell (hollow body)."""
pass
@abstractmethod
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
"""Offset a face or surface."""
pass
@abstractmethod
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
"""Translate a body."""
pass
@abstractmethod
def rotate(
self,
body: GeometryObject,
axis: Tuple[float, float, float],
angle: float,
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Rotate a body around an axis."""
pass
@abstractmethod
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
"""Scale a body uniformly."""
pass
@abstractmethod
def mirror(
self,
body: GeometryObject,
plane_normal: Tuple[float, float, float],
plane_origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Mirror a body across a plane."""
pass
@abstractmethod
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
pass
@abstractmethod
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
"""Export to IGES format."""
pass
@abstractmethod
def export_stl(
self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
) -> bool:
"""Export to STL format."""
pass
@abstractmethod
def import_step(self, filepath: str) -> GeometryObject:
"""Import from STEP format."""
pass
@abstractmethod
def import_iges(self, filepath: str) -> GeometryObject:
"""Import from IGES format."""
pass
@abstractmethod
def get_mesh(
self, body: GeometryObject, tolerance: float = 0.1
) -> Tuple[np.ndarray, np.ndarray]:
"""
Get triangulated mesh for rendering.
Returns:
Tuple of (vertices, faces) where:
- vertices: Nx3 numpy array of vertex positions
- faces: Mx3 numpy array of triangle indices
"""
pass
@abstractmethod
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
"""
Get edge wireframe for rendering.
Returns:
Tuple of (vertices, edges) where:
- vertices: Nx3 numpy array of vertex positions
- edges: Mx2 numpy array of edge vertex indices
"""
pass
@abstractmethod
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
"""Get the bounding box of a body."""
pass
@abstractmethod
def get_volume(self, body: GeometryObject) -> float:
"""Calculate the volume of a solid body."""
pass
@abstractmethod
def get_surface_area(self, body: GeometryObject) -> float:
"""Calculate the surface area of a body."""
pass
@abstractmethod
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
"""Calculate the center of mass of a solid body."""
pass
class SketchInterface(ABC):
"""
Abstract interface for 2D sketching with constraints.
A sketch provides 2D geometry creation and constraint solving
capabilities for parametric CAD.
"""
@abstractmethod
def add_point(self, x: float, y: float) -> SketchEntity:
"""Add a point to the sketch."""
pass
@abstractmethod
def add_line(self, start: SketchEntity, end: SketchEntity) -> SketchEntity:
"""Add a line between two points."""
pass
@abstractmethod
def add_circle(self, center: SketchEntity, radius: float) -> SketchEntity:
"""Add a circle."""
pass
@abstractmethod
def add_arc(
self,
center: SketchEntity,
radius: float,
start_point: SketchEntity,
end_point: SketchEntity,
) -> SketchEntity:
"""Add an arc."""
pass
@abstractmethod
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[SketchEntity]:
"""Add a rectangle, returning the created entities."""
pass
@abstractmethod
def constrain_coincident(self, *entities: SketchEntity) -> bool:
"""Make entities coincident."""
pass
@abstractmethod
def constrain_horizontal(self, line: SketchEntity) -> bool:
"""Constrain a line to be horizontal."""
pass
@abstractmethod
def constrain_vertical(self, line: SketchEntity) -> bool:
"""Constrain a line to be vertical."""
pass
@abstractmethod
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
"""Constrain distance between two entities."""
pass
@abstractmethod
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
"""Constrain angle between two lines."""
pass
@abstractmethod
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be parallel."""
pass
@abstractmethod
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be perpendicular."""
pass
@abstractmethod
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
"""Constrain a point to be at the midpoint of a line."""
pass
@abstractmethod
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
"""Constrain two entities to be tangent."""
pass
@abstractmethod
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to have equal length."""
pass
@abstractmethod
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
"""Constrain two circles to have equal radius."""
pass
@abstractmethod
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place."""
pass
@abstractmethod
def solve(self) -> bool:
"""Solve all constraints."""
pass
@abstractmethod
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations."""
pass
@abstractmethod
def get_points(self) -> List[Point2D]:
"""Get all point positions."""
pass
@abstractmethod
def clear(self) -> None:
"""Clear all geometry and constraints."""
pass
@abstractmethod
def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints."""
pass
+11
View File
@@ -0,0 +1,11 @@
"""OpenCASCADE geometry module."""
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch, OCCSketchEntity
__all__ = [
"OCGeometryKernel",
"OCCGeometryObject",
"OCCSketch",
"OCCSketchEntity",
]
+726
View File
@@ -0,0 +1,726 @@
"""
OpenCASCADE-based geometry kernel for Fluency CAD.
This module provides a concrete implementation of the geometry kernel
using CadQuery and OCP (OpenCASCADE Python bindings).
"""
from typing import List, Tuple, Optional, Any, Dict
import numpy as np
from fluency.geometry.base import (
GeometryKernel,
GeometryObject,
Point2D,
Point3D,
)
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
def __init__(self, shape: Any = None, metadata: Optional[Dict] = None):
super().__init__(shape, metadata)
self._cadquery_obj: Any = None
@property
def cq_obj(self) -> Any:
"""Get the CadQuery object if available."""
return self._cadquery_obj
@cq_obj.setter
def cq_obj(self, value: Any) -> None:
self._cadquery_obj = value
class OCGeometryKernel(GeometryKernel):
"""
OpenCASCADE-based geometry kernel implementation.
This kernel uses CadQuery for high-level operations and
OCP for direct OpenCASCADE access when needed.
"""
def __init__(self) -> None:
self._tolerance: float = 0.001
self._mesh_tolerance: float = 0.1
def _get_shape(self, obj: GeometryObject) -> Any:
"""Extract the underlying OCC shape from a GeometryObject."""
if isinstance(obj, OCCGeometryObject):
if obj._cadquery_obj is not None:
shape = obj._cadquery_obj.val()
if hasattr(shape, "wrapped"):
return shape.wrapped
return shape
if obj.shape is not None:
if hasattr(obj.shape, "wrapped"):
return obj.shape.wrapped
return obj.shape
return obj.shape if obj.shape else obj
def _get_cq_obj(self, obj: GeometryObject) -> Any:
"""Get CadQuery object from GeometryObject."""
if isinstance(obj, OCCGeometryObject) and obj._cadquery_obj is not None:
return obj._cadquery_obj
return obj.shape
def create_point(self, x: float, y: float) -> GeometryObject:
"""Create a 2D point."""
import cadquery as cq
point = cq.Vector(x, y, 0)
return OCCGeometryObject(point)
def create_line(self, start: Point2D, end: Point2D) -> GeometryObject:
"""Create a 2D line segment."""
import cadquery as cq
wire = cq.Workplane("XY").moveTo(start.x, start.y).lineTo(end.x, end.y)
return OCCGeometryObject(wire.val(), {"type": "line"})
def create_circle(self, center: Point2D, radius: float) -> GeometryObject:
"""Create a 2D circle."""
import cadquery as cq
wire = cq.Workplane("XY").center(center.x, center.y).circle(radius)
return OCCGeometryObject(wire.val(), {"type": "circle"})
def create_arc(
self, center: Point2D, radius: float, start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
import cadquery as cq
import math
start_rad = math.radians(start_angle)
end_rad = math.radians(end_angle)
start_x = center.x + radius * math.cos(start_rad)
start_y = center.y + radius * math.sin(start_rad)
wire = (
cq.Workplane("XY")
.moveTo(start_x, start_y)
.radiusArc(
(center.x + radius * math.cos(end_rad), center.y + radius * math.sin(end_rad)),
radius,
)
)
return OCCGeometryObject(wire.val(), {"type": "arc"})
def create_polygon(self, points: List[Point2D]) -> GeometryObject:
"""Create a closed polygon from points."""
import cadquery as cq
if len(points) < 3:
raise ValueError("Polygon requires at least 3 points")
wp = cq.Workplane("XY").moveTo(points[0].x, points[0].y)
for pt in points[1:]:
wp = wp.lineTo(pt.x, pt.y)
wp = wp.close()
return OCCGeometryObject(wp.val(), {"type": "polygon"})
def create_rectangle(
self, width: float, height: float, center: Optional[Point2D] = None
) -> GeometryObject:
"""Create a rectangle."""
import cadquery as cq
cx = center.x if center else 0
cy = center.y if center else 0
wire = cq.Workplane("XY").center(cx, cy).rect(width, height)
return OCCGeometryObject(wire.val(), {"type": "rectangle"})
def extrude(
self,
sketch: GeometryObject,
height: float,
direction: Tuple[float, float, float] = (0, 0, 1),
symmetric: bool = False,
) -> GeometryObject:
"""Extrude a 2D sketch into a 3D solid."""
import cadquery as cq
cq_obj = self._get_cq_obj(sketch)
if symmetric:
half_height = height / 2
if isinstance(cq_obj, cq.Workplane):
solid = cq_obj.extrude(half_height, both=True)
else:
face = cq.Face.makeFromWires(cq_obj)
solid = face.extrude(cq.Vector(0, 0, half_height) * 2)
else:
if isinstance(cq_obj, cq.Workplane):
solid = cq_obj.extrude(height)
else:
face = cq.Face.makeFromWires(cq_obj)
dir_vec = cq.Vector(*direction).normalized() * height
solid = face.extrude(dir_vec)
return OCCGeometryObject(solid, {"type": "extrusion"})
def revolve(
self,
sketch: GeometryObject,
angle: float = 360.0,
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Revolve a 2D sketch around an axis."""
import cadquery as cq
cq_obj = self._get_cq_obj(sketch)
if isinstance(cq_obj, cq.Workplane):
solid = cq_obj.revolve(angle)
else:
face = cq.Face.makeFromWires(cq_obj)
axis_vec = cq.Vector(*axis)
origin_vec = cq.Vector(*origin)
solid = face.revolve(axis_vec, origin_vec, angle)
return OCCGeometryObject(solid, {"type": "revolution"})
def loft(self, profiles: List[GeometryObject], ruled: bool = False) -> GeometryObject:
"""Create a loft between multiple profiles."""
import cadquery as cq
if len(profiles) < 2:
raise ValueError("Loft requires at least 2 profiles")
wires = []
for profile in profiles:
cq_obj = self._get_cq_obj(profile)
if isinstance(cq_obj, cq.Workplane):
wires.append(cq_obj.val())
else:
wires.append(cq_obj)
loft = cq.Solid.loft(wires, ruled)
return OCCGeometryObject(loft, {"type": "loft"})
def sweep(
self, profile: GeometryObject, path: GeometryObject, is_frenet: bool = False
) -> GeometryObject:
"""Sweep a profile along a path."""
import cadquery as cq
profile_obj = self._get_cq_obj(profile)
path_obj = self._get_cq_obj(path)
if isinstance(profile_obj, cq.Workplane):
profile_wire = profile_obj.val()
else:
profile_wire = profile_obj
if isinstance(path_obj, cq.Workplane):
path_wire = path_obj.val()
else:
path_wire = path_obj
solid = cq.Solid.sweep(profile_wire, path_wire, is_frenet)
return OCCGeometryObject(solid, {"type": "sweep"})
def boolean_union(self, *bodies: GeometryObject) -> GeometryObject:
"""Union multiple bodies."""
import cadquery as cq
if len(bodies) < 2:
return bodies[0] if bodies else OCCGeometryObject(None)
result = self._get_shape(bodies[0])
for body in bodies[1:]:
shape = self._get_shape(body)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
fuse = BRepAlgoAPI_Fuse(result, shape)
fuse.Build()
result = fuse.Shape()
return OCCGeometryObject(cq.Shape(result), {"type": "union"})
def boolean_difference(self, base: GeometryObject, tool: GeometryObject) -> GeometryObject:
"""Subtract tool from base."""
import cadquery as cq
base_shape = self._get_shape(base)
tool_shape = self._get_shape(tool)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
cut = BRepAlgoAPI_Cut(base_shape, tool_shape)
cut.Build()
return OCCGeometryObject(cq.Shape(cut.Shape()), {"type": "difference"})
def boolean_intersection(self, body1: GeometryObject, body2: GeometryObject) -> GeometryObject:
"""Intersect two bodies."""
import cadquery as cq
shape1 = self._get_shape(body1)
shape2 = self._get_shape(body2)
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common
common = BRepAlgoAPI_Common(shape1, shape2)
common.Build()
return OCCGeometryObject(cq.Shape(common.Shape()), {"type": "intersection"})
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
if edges:
result = cq_obj.edges(edges).fillet(radius)
else:
result = cq_obj.edges().fillet(radius)
else:
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
fillet.Add(radius, explorer.Current())
explorer.Next()
result = cq.Shape(fillet.Shape())
return OCCGeometryObject(result, {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
if edges:
result = cq_obj.edges(edges).chamfer(size)
else:
result = cq_obj.edges().chamfer(size)
else:
shape = self._get_shape(body)
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
chamfer.Add(size, explorer.Current())
explorer.Next()
result = cq.Shape(chamfer.Shape())
return OCCGeometryObject(result, {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
) -> GeometryObject:
"""Create a shell (hollow body)."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
if faces_to_remove:
result = cq_obj.faces(faces_to_remove).shell(thickness)
else:
result = cq_obj.shell(thickness)
else:
shape = self._get_shape(body)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeThickSolid
from OCP.TopTools import TopTools_ListOfShape
faces_list = TopTools_ListOfShape()
if faces_to_remove:
for face in faces_to_remove:
faces_list.Append(face)
shell_maker = BRepOffsetAPI_MakeThickSolid()
shell_maker.MakeThickSolidByJoin(shape, faces_list, thickness, 0.001)
shell_maker.Build()
result = cq.Shape(shell_maker.Shape())
return OCCGeometryObject(result, {"type": "shell"})
def offset(self, face: GeometryObject, distance: float) -> GeometryObject:
"""Offset a face or surface."""
import cadquery as cq
shape = self._get_shape(face)
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
offset_maker = BRepOffsetAPI_MakeOffset(shape, False)
offset_maker.Perform(distance)
return OCCGeometryObject(cq.Shape(offset_maker.Shape()), {"type": "offset"})
def translate(self, body: GeometryObject, vector: Tuple[float, float, float]) -> GeometryObject:
"""Translate a body."""
import cadquery as cq
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
result = cq_obj.translate(vector)
else:
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Vec
transform = gp_Trsf()
transform.SetTranslation(gp_Vec(*vector))
transformer = BRepBuilderAPI_Transform(shape, transform)
result = cq.Shape(transformer.Shape())
return OCCGeometryObject(result, {"type": "translated"})
def rotate(
self,
body: GeometryObject,
axis: Tuple[float, float, float],
angle: float,
origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Rotate a body around an axis."""
import cadquery as cq
import math
cq_obj = self._get_cq_obj(body)
if isinstance(cq_obj, cq.Workplane):
result = cq_obj.rotate(origin, axis, math.degrees(angle))
else:
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax1, gp_Pnt, gp_Dir, gp_Vec
ax1 = gp_Ax1(gp_Pnt(*origin), gp_Dir(*axis))
transform = gp_Trsf()
transform.SetRotation(ax1, angle)
transformer = BRepBuilderAPI_Transform(shape, transform)
result = cq.Shape(transformer.Shape())
return OCCGeometryObject(result, {"type": "rotated"})
def scale(self, body: GeometryObject, factor: float) -> GeometryObject:
"""Scale a body uniformly."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf
transform = gp_Trsf()
transform.SetScale(factor)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "scaled"})
def mirror(
self,
body: GeometryObject,
plane_normal: Tuple[float, float, float],
plane_origin: Tuple[float, float, float] = (0, 0, 0),
) -> GeometryObject:
"""Mirror a body across a plane."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.gp import gp_Trsf, gp_Ax2, gp_Pnt, gp_Dir
ax2 = gp_Ax2(gp_Pnt(*plane_origin), gp_Dir(*plane_normal))
transform = gp_Trsf()
transform.SetMirror(ax2)
transformer = BRepBuilderAPI_Transform(shape, transform)
return OCCGeometryObject(cq.Shape(transformer.Shape()), {"type": "mirrored"})
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
try:
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "exportStep"):
shape.exportStep(filepath)
return True
from OCP.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCP.Interface import Interface_Static
writer = STEPControl_Writer()
if schema == "AP214":
Interface_Static.SetCVal_s("write.step.schema", "AP214")
elif schema == "AP203":
Interface_Static.SetCVal_s("write.step.schema", "AP203")
writer.Transfer(shape, STEPControl_AsIs)
writer.Write(filepath)
return True
except Exception as e:
print(f"STEP export error: {e}")
return False
def export_iges(self, body: GeometryObject, filepath: str) -> bool:
"""Export to IGES format."""
try:
import cadquery as cq
shape = self._get_shape(body)
from OCP.IGESControl import IGESControl_Writer
from OCP.Interface import Interface_Static
Interface_Static.SetCVal_s("write.iges.schema", "5.3")
writer = IGESControl_Writer()
writer.AddShape(shape)
writer.Write(filepath)
return True
except Exception as e:
print(f"IGES export error: {e}")
return False
def export_stl(
self, body: GeometryObject, filepath: str, tolerance: float = 0.1, ascii_mode: bool = False
) -> bool:
"""Export to STL format."""
try:
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "exportStl"):
shape.exportStl(filepath, tolerance)
return True
from OCP.StlAPI import StlAPI_Writer
from OCP.BRepMesh import BRepMesh_IncrementalMesh
mesh = BRepMesh_IncrementalMesh(shape, tolerance)
mesh.Perform()
writer = StlAPI_Writer()
writer.ASCIIMode = ascii_mode
writer.Write(shape, filepath)
return True
except Exception as e:
print(f"STL export error: {e}")
return False
def import_step(self, filepath: str) -> GeometryObject:
"""Import from STEP format."""
import cadquery as cq
result = cq.importers.importStep(filepath)
return OCCGeometryObject(result, {"type": "imported_step"})
def import_iges(self, filepath: str) -> GeometryObject:
"""Import from IGES format."""
import cadquery as cq
from OCP.IGESControl import IGESControl_Reader
from OCP.IFSelect import IFSelect_RetDone
reader = IGESControl_Reader()
status = reader.ReadFile(filepath)
if status != IFSelect_RetDone:
raise ValueError(f"Failed to read IGES file: {filepath}")
reader.TransferRoots()
shape = reader.OneShape()
return OCCGeometryObject(cq.Shape(shape), {"type": "imported_iges"})
def get_mesh(
self, body: GeometryObject, tolerance: float = 0.1
) -> Tuple[np.ndarray, np.ndarray]:
"""Get triangulated mesh for rendering."""
import cadquery as cq
shape = self._get_shape(body)
if hasattr(shape, "tessellate"):
vertices, faces = shape.tessellate(tolerance)
return np.array(vertices), np.array(faces)
from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_FACE
from OCP.BRep import BRep_Tool
from OCP.Poly import Poly_Triangulation
from OCP.TopLoc import TopLoc_Location
mesh = BRepMesh_IncrementalMesh(shape, tolerance)
mesh.Perform()
vertices_list: List[List[float]] = []
faces_list: List[List[int]] = []
vertex_offset = 0
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = explorer.Current()
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is not None:
n_vertices = triangulation.NbNodes()
for i in range(1, n_vertices + 1):
p = triangulation.Node(i)
vertices_list.append([p.X(), p.Y(), p.Z()])
n_triangles = triangulation.NbTriangles()
for i in range(1, n_triangles + 1):
tri = triangulation.Triangle(i)
faces_list.append(
[
tri.Value(1) - 1 + vertex_offset,
tri.Value(2) - 1 + vertex_offset,
tri.Value(3) - 1 + vertex_offset,
]
)
vertex_offset += n_vertices
explorer.Next()
return np.array(vertices_list, dtype=np.float32), np.array(faces_list, dtype=np.int32)
def get_edges(self, body: GeometryObject) -> Tuple[np.ndarray, np.ndarray]:
"""Get edge wireframe for rendering."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.BRep import BRep_Tool
from OCP.TopLoc import TopLoc_Location
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_BSplineCurve
vertices_list: List[List[float]] = []
edges_list: List[List[int]] = []
vertex_offset = 0
def discretize_edge(edge: Any, num_points: int = 20) -> List[List[float]]:
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
points = []
if curve_type == GeomAbs_Line:
first = curve.FirstParameter()
last = curve.LastParameter()
p1 = curve.Value(first)
p2 = curve.Value(last)
points = [[p1.X(), p1.Y(), p1.Z()], [p2.X(), p2.Y(), p2.Z()]]
else:
first = curve.FirstParameter()
last = curve.LastParameter()
for i in range(num_points + 1):
t = first + (last - first) * i / num_points
p = curve.Value(t)
points.append([p.X(), p.Y(), p.Z()])
return points
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
while explorer.More():
edge = explorer.Current()
edge_points = discretize_edge(edge)
for i, pt in enumerate(edge_points):
vertices_list.append(pt)
if i < len(edge_points) - 1:
edges_list.append([vertex_offset + i, vertex_offset + i + 1])
vertex_offset += len(edge_points)
explorer.Next()
return np.array(vertices_list, dtype=np.float32), np.array(edges_list, dtype=np.int32)
def get_bounding_box(self, body: GeometryObject) -> Tuple[Point3D, Point3D]:
"""Get the bounding box of a body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.Bnd import Bnd_Box
from OCP.BRepBndLib import BRepBndLib_AddClose
bbox = Bnd_Box()
BRepBndLib_AddClose(shape, bbox)
xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get()
return Point3D(xmin, ymin, zmin), Point3D(xmax, ymax, zmax)
def get_volume(self, body: GeometryObject) -> float:
"""Calculate the volume of a solid body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp_VolumeProperties
props = GProp_GProps()
BRepGProp_VolumeProperties(shape, props)
return props.Mass()
def get_surface_area(self, body: GeometryObject) -> float:
"""Calculate the surface area of a body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp_SurfaceProperties
props = GProp_GProps()
BRepGProp_SurfaceProperties(shape, props)
return props.Mass()
def get_center_of_mass(self, body: GeometryObject) -> Point3D:
"""Calculate the center of mass of a solid body."""
import cadquery as cq
shape = self._get_shape(body)
from OCP.GProp import GProp_GProps
from OCP.BRepGProp import BRepGProp_VolumeProperties
props = GProp_GProps()
BRepGProp_VolumeProperties(shape, props)
cg = props.CentreOfMass()
return Point3D(cg.X(), cg.Y(), cg.Z())
+383
View File
@@ -0,0 +1,383 @@
"""
OpenCASCADE-based sketch with constraint solving for Fluency CAD.
This module provides 2D sketching with parametric constraints using
CadQuery's built-in constraint solver.
"""
from typing import List, Tuple, Optional, Dict, Any
from dataclasses import dataclass, field
import numpy as np
from fluency.geometry.base import (
SketchInterface,
SketchEntity,
GeometryObject,
Point2D,
)
from fluency.geometry_occ.kernel import OCCGeometryObject
@dataclass
class OCCSketchEntity(SketchEntity):
"""Sketch entity for OpenCASCADE-based sketch."""
geometry: Any = None
handle: Any = None
def __post_init__(self) -> None:
if self.constraints is None:
self.constraints = []
class OCCSketch(SketchInterface):
"""
CadQuery-based sketch with constraint solving.
This sketch uses CadQuery's Sketch class which provides
built-in constraint solving capabilities.
"""
def __init__(self) -> None:
import cadquery as cq
self._sketch = cq.Sketch()
self._entities: Dict[int, OCCSketchEntity] = {}
self._entity_counter: int = 0
self._points: Dict[int, Tuple[float, float]] = {}
self._lines: Dict[int, Tuple[int, int]] = {}
self._circles: Dict[int, Tuple[int, float]] = {}
self._arcs: Dict[int, Any] = {}
self._constraint_count: int = 0
def _next_id(self) -> int:
self._entity_counter += 1
return self._entity_counter
def add_point(self, x: float, y: float) -> OCCSketchEntity:
"""Add a point to the sketch."""
entity_id = self._next_id()
self._sketch = self._sketch.point(x, y)
entity = OCCSketchEntity(entity_id=entity_id, entity_type="point", geometry=(x, y))
self._entities[entity_id] = entity
self._points[entity_id] = (x, y)
return entity
def add_line(self, start: SketchEntity, end: SketchEntity) -> OCCSketchEntity:
"""Add a line between two points."""
entity_id = self._next_id()
start_geom = self._entities.get(start.id)
end_geom = self._entities.get(end.id)
if start_geom is None or end_geom is None:
raise ValueError("Start or end point not found in sketch")
x1, y1 = start_geom.geometry
x2, y2 = end_geom.geometry
self._sketch = self._sketch.line((x1, y1), (x2, y2))
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="line", geometry=((x1, y1), (x2, y2))
)
self._entities[entity_id] = entity
self._lines[entity_id] = (start.id, end.id)
return entity
def add_circle(self, center: SketchEntity, radius: float) -> OCCSketchEntity:
"""Add a circle."""
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
if center_entity is None:
raise ValueError("Center point not found in sketch")
cx, cy = center_entity.geometry
self._sketch = self._sketch.circle((cx, cy), radius)
entity = OCCSketchEntity(
entity_id=entity_id, entity_type="circle", geometry=((cx, cy), radius)
)
self._entities[entity_id] = entity
self._circles[entity_id] = (center.id, radius)
return entity
def add_arc(
self,
center: SketchEntity,
radius: float,
start_point: SketchEntity,
end_point: SketchEntity,
) -> OCCSketchEntity:
"""Add an arc."""
entity_id = self._next_id()
center_entity = self._entities.get(center.id)
start_entity = self._entities.get(start_point.id)
end_entity = self._entities.get(end_point.id)
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
cx, cy = center_entity.geometry
sx, sy = start_entity.geometry
ex, ey = end_entity.geometry
self._sketch = self._sketch.arc((sx, sy), (ex, ey), (cx, cy))
entity = OCCSketchEntity(
entity_id=entity_id,
entity_type="arc",
geometry={"center": (cx, cy), "radius": radius, "start": (sx, sy), "end": (ex, ey)},
)
self._entities[entity_id] = entity
self._arcs[entity_id] = {
"center": center.id,
"start": start_point.id,
"end": end_point.id,
"radius": radius,
}
return entity
def add_rectangle(
self, corner1: Tuple[float, float], corner2: Tuple[float, float]
) -> List[OCCSketchEntity]:
"""Add a rectangle, returning the created entities."""
x1, y1 = corner1
x2, y2 = corner2
entities: List[OCCSketchEntity] = []
p1 = self.add_point(x1, y1)
p2 = self.add_point(x2, y1)
p3 = self.add_point(x2, y2)
p4 = self.add_point(x1, y2)
entities.extend([p1, p2, p3, p4])
l1 = self.add_line(p1, p2)
l2 = self.add_line(p2, p3)
l3 = self.add_line(p3, p4)
l4 = self.add_line(p4, p1)
entities.extend([l1, l2, l3, l4])
return entities
def constrain_coincident(self, *entities: SketchEntity) -> bool:
"""Make entities coincident."""
if len(entities) < 2:
return False
ids = [e.id for e in entities]
self._sketch = self._sketch.constrain(ids[0], ids[1], "Coincident")
self._constraint_count += 1
return True
def constrain_horizontal(self, line: SketchEntity) -> bool:
"""Constrain a line to be horizontal."""
self._sketch = self._sketch.constrain(line.id, "Horizontal")
self._constraint_count += 1
return True
def constrain_vertical(self, line: SketchEntity) -> bool:
"""Constrain a line to be vertical."""
self._sketch = self._sketch.constrain(line.id, "Vertical")
self._constraint_count += 1
return True
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
"""Constrain distance between two entities."""
self._sketch = self._sketch.constrain(entity1.id, entity2.id, "Distance", distance)
self._constraint_count += 1
return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
"""Constrain angle between two lines."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "Angle", angle)
self._constraint_count += 1
return True
def constrain_parallel(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be parallel."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "Parallel")
self._constraint_count += 1
return True
def constrain_perpendicular(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to be perpendicular."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "Perpendicular")
self._constraint_count += 1
return True
def constrain_midpoint(self, point: SketchEntity, line: SketchEntity) -> bool:
"""Constrain a point to be at the midpoint of a line."""
self._sketch = self._sketch.constrain(point.id, line.id, "Midpoint")
self._constraint_count += 1
return True
def constrain_tangent(self, entity1: SketchEntity, entity2: SketchEntity) -> bool:
"""Constrain two entities to be tangent."""
self._sketch = self._sketch.constrain(entity1.id, entity2.id, "Tangent")
self._constraint_count += 1
return True
def constrain_equal_length(self, line1: SketchEntity, line2: SketchEntity) -> bool:
"""Constrain two lines to have equal length."""
self._sketch = self._sketch.constrain(line1.id, line2.id, "EqualLength")
self._constraint_count += 1
return True
def constrain_equal_radius(self, circle1: SketchEntity, circle2: SketchEntity) -> bool:
"""Constrain two circles to have equal radius."""
self._sketch = self._sketch.constrain(circle1.id, circle2.id, "EqualRadius")
self._constraint_count += 1
return True
def constrain_fixed(self, entity: SketchEntity) -> bool:
"""Fix an entity in place."""
self._sketch = self._sketch.constrain(entity.id, "Fixed")
self._constraint_count += 1
return True
def solve(self) -> bool:
"""Solve all constraints."""
try:
self._sketch = self._sketch.solve()
self._update_entity_geometry()
return True
except Exception as e:
print(f"Solver error: {e}")
return False
def _update_entity_geometry(self) -> None:
"""Update entity geometry after solving."""
pass
def get_geometry(self) -> GeometryObject:
"""Get the solved geometry for operations."""
return OCCGeometryObject(self._sketch.val())
def get_points(self) -> List[Point2D]:
"""Get all point positions."""
points: List[Point2D] = []
for entity_id, entity in self._entities.items():
if entity.entity_type == "point":
x, y = entity.geometry
points.append(Point2D(x, y))
return points
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines."""
adjacency: Dict[Tuple[float, float], List[Tuple[float, float]]] = {}
for entity in self._entities.values():
if entity.entity_type == "line":
p1, p2 = entity.geometry
if p1 not in adjacency:
adjacency[p1] = []
if p2 not in adjacency:
adjacency[p2] = []
adjacency[p1].append(p2)
adjacency[p2].append(p1)
if not adjacency:
return []
points: List[Point2D] = []
visited: set = set()
current = next(iter(adjacency.keys()))
while current and current not in visited:
points.append(Point2D(current[0], current[1]))
visited.add(current)
neighbors = adjacency.get(current, [])
next_point = None
for n in neighbors:
if n not in visited:
next_point = n
break
current = next_point
if len(points) > 2:
points.append(points[0])
return points
def clear(self) -> None:
"""Clear all geometry and constraints."""
import cadquery as cq
self._sketch = cq.Sketch()
self._entities.clear()
self._points.clear()
self._lines.clear()
self._circles.clear()
self._arcs.clear()
self._entity_counter = 0
self._constraint_count = 0
def delete_entity(self, entity: SketchEntity) -> bool:
"""Delete an entity and its constraints."""
if entity.id not in self._entities:
return False
del self._entities[entity.id]
if entity.id in self._points:
del self._points[entity.id]
if entity.id in self._lines:
del self._lines[entity.id]
if entity.id in self._circles:
del self._circles[entity.id]
if entity.id in self._arcs:
del self._arcs[entity.id]
return True
def get_sketch_object(self) -> Any:
"""Get the underlying CadQuery sketch object."""
return self._sketch
def get_entity_count(self) -> int:
"""Get the number of entities in the sketch."""
return len(self._entities)
def get_constraint_count(self) -> int:
"""Get the number of constraints in the sketch."""
return self._constraint_count
def is_fully_constrained(self) -> bool:
"""Check if the sketch is fully constrained."""
return self._sketch.is_fully_constrained()
+553
View File
@@ -0,0 +1,553 @@
"""
Fluency CAD - Main Application
A parametric CAD application built on OpenCASCADE Technology (OCCT)
with a modern pygfx-based 3D renderer.
"""
import sys
from typing import Optional, List
from PySide6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QToolBar,
QStatusBar,
QFileDialog,
QMessageBox,
QDockWidget,
QTreeWidget,
QTreeWidgetItem,
QLabel,
QDoubleSpinBox,
QComboBox,
QPushButton,
QGroupBox,
)
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QAction, QIcon, QKeySequence
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.geometry_occ.sketch import OCCSketch
from fluency.rendering.pygfx_renderer import PygfxRenderer
from fluency.models.data_model import Project, Component, Sketch, Body
class SketchWidget(QWidget):
"""2D sketching widget."""
sketch_changed = Signal()
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._sketch: Optional[OCCSketch] = None
self._mode: str = "select"
self._points: List = []
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
toolbar = QToolBar()
toolbar.addAction("Select", lambda: self._set_mode("select"))
toolbar.addAction("Line", lambda: self._set_mode("line"))
toolbar.addAction("Rectangle", lambda: self._set_mode("rectangle"))
toolbar.addAction("Circle", lambda: self._set_mode("circle"))
toolbar.addSeparator()
toolbar.addAction("Coincident", self._add_coincident_constraint)
toolbar.addAction("Horizontal", self._add_horizontal_constraint)
toolbar.addAction("Vertical", self._add_vertical_constraint)
toolbar.addAction("Distance", self._add_distance_constraint)
layout.addWidget(toolbar)
self._canvas = QLabel("Sketch Canvas (Click to draw)")
self._canvas.setMinimumSize(400, 300)
self._canvas.setStyleSheet("background-color: #1a1a2e; color: white;")
self._canvas.setAlignment(Qt.AlignCenter)
layout.addWidget(self._canvas)
def _set_mode(self, mode: str) -> None:
self._mode = mode
self._canvas.setText(f"Mode: {mode.upper()}")
def _add_coincident_constraint(self) -> None:
pass
def _add_horizontal_constraint(self) -> None:
pass
def _add_vertical_constraint(self) -> None:
pass
def _add_distance_constraint(self) -> None:
pass
def set_sketch(self, sketch: Optional[OCCSketch]) -> None:
self._sketch = sketch
def get_sketch(self) -> Optional[OCCSketch]:
return self._sketch
class PropertiesWidget(QWidget):
"""Properties panel widget."""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
extrude_group = QGroupBox("Extrude")
extrude_layout = QVBoxLayout(extrude_group)
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Height:"))
self._height_spin = QDoubleSpinBox()
self._height_spin.setRange(-10000, 10000)
self._height_spin.setValue(10)
height_layout.addWidget(self._height_spin)
extrude_layout.addLayout(height_layout)
self._extrude_btn = QPushButton("Extrude")
extrude_layout.addWidget(self._extrude_btn)
layout.addWidget(extrude_group)
fillet_group = QGroupBox("Fillet")
fillet_layout = QVBoxLayout(fillet_group)
radius_layout = QHBoxLayout()
radius_layout.addWidget(QLabel("Radius:"))
self._fillet_spin = QDoubleSpinBox()
self._fillet_spin.setRange(0.01, 1000)
self._fillet_spin.setValue(1)
radius_layout.addWidget(self._fillet_spin)
fillet_layout.addLayout(radius_layout)
self._fillet_btn = QPushButton("Apply Fillet")
fillet_layout.addWidget(self._fillet_btn)
layout.addWidget(fillet_group)
layout.addStretch()
def get_extrude_height(self) -> float:
return self._height_spin.value()
def get_fillet_radius(self) -> float:
return self._fillet_spin.value()
class BrowserWidget(QWidget):
"""Feature browser tree widget."""
item_selected = Signal(str, str)
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._tree = QTreeWidget()
self._tree.setHeaderLabel("Features")
self._tree.itemClicked.connect(self._on_item_clicked)
layout.addWidget(self._tree)
def _on_item_clicked(self, item: QTreeWidgetItem, column: int) -> None:
data = item.data(0, Qt.UserRole)
if data:
item_type, item_id = data.split(":")
self.item_selected.emit(item_type, item_id)
def update_from_project(self, project: Project) -> None:
self._tree.clear()
for comp_id, comp in project.components.items():
comp_item = QTreeWidgetItem([comp.name])
comp_item.setData(0, Qt.UserRole, f"component:{comp_id}")
self._tree.addTopLevelItem(comp_item)
sketches_item = QTreeWidgetItem(["Sketches"])
comp_item.addChild(sketches_item)
for sketch_id, sketch in comp.sketches.items():
sketch_item = QTreeWidgetItem([sketch.name])
sketch_item.setData(0, Qt.UserRole, f"sketch:{sketch_id}")
sketches_item.addChild(sketch_item)
bodies_item = QTreeWidgetItem(["Bodies"])
comp_item.addChild(bodies_item)
for body_id, body in comp.bodies.items():
body_item = QTreeWidgetItem([body.name])
body_item.setData(0, Qt.UserRole, f"body:{body_id}")
bodies_item.addChild(body_item)
comp_item.setExpanded(True)
class MainWindow(QMainWindow):
"""Main application window."""
def __init__(self) -> None:
super().__init__()
self._project = Project()
self._kernel = OCGeometryKernel()
self._renderer = PygfxRenderer()
self._current_sketch: Optional[Sketch] = None
self._selected_body: Optional[Body] = None
self._setup_ui()
self._setup_connections()
self._create_initial_component()
def _setup_ui(self) -> None:
self.setWindowTitle("Fluency CAD 2.0")
self.setMinimumSize(1200, 800)
self._create_menus()
self._create_toolbars()
self._create_dock_widgets()
self._create_central_widget()
self.statusBar().showMessage("Ready")
def _create_menus(self) -> None:
menubar = self.menuBar()
file_menu = menubar.addMenu("&File")
new_action = QAction("&New Project", self)
new_action.setShortcut(QKeySequence.New)
new_action.triggered.connect(self._new_project)
file_menu.addAction(new_action)
open_action = QAction("&Open...", self)
open_action.setShortcut(QKeySequence.Open)
open_action.triggered.connect(self._open_project)
file_menu.addAction(open_action)
file_menu.addSeparator()
export_step = QAction("Export &STEP...", self)
export_step.triggered.connect(self._export_step)
file_menu.addAction(export_step)
export_iges = QAction("Export &IGES...", self)
export_iges.triggered.connect(self._export_iges)
file_menu.addAction(export_iges)
export_stl = QAction("Export S&TL...", self)
export_stl.triggered.connect(self._export_stl)
file_menu.addAction(export_stl)
file_menu.addSeparator()
exit_action = QAction("E&xit", self)
exit_action.setShortcut(QKeySequence.Quit)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
edit_menu = menubar.addMenu("&Edit")
edit_menu.addAction("Undo")
edit_menu.addAction("Redo")
edit_menu.addSeparator()
edit_menu.addAction("Delete")
view_menu = menubar.addMenu("&View")
view_menu.addAction("Fit All", self._fit_view)
view_menu.addAction("Reset View", self._reset_view)
view_menu.addSeparator()
iso_view = QAction("Isometric", self)
iso_view.triggered.connect(lambda: self._set_view("iso"))
view_menu.addAction(iso_view)
top_view = QAction("Top", self)
top_view.triggered.connect(lambda: self._set_view("top"))
view_menu.addAction(top_view)
front_view = QAction("Front", self)
front_view.triggered.connect(lambda: self._set_view("front"))
view_menu.addAction(front_view)
right_view = QAction("Right", self)
right_view.triggered.connect(lambda: self._set_view("right"))
view_menu.addAction(right_view)
help_menu = menubar.addMenu("&Help")
help_menu.addAction("About", self._show_about)
def _create_toolbars(self) -> None:
toolbar = self.addToolBar("Main")
toolbar.addAction("New Sketch", self._new_sketch)
toolbar.addSeparator()
toolbar.addAction("Extrude", self._extrude_sketch)
toolbar.addAction("Revolve", self._revolve_sketch)
toolbar.addSeparator()
toolbar.addAction("Union", self._boolean_union)
toolbar.addAction("Subtract", self._boolean_subtract)
toolbar.addAction("Intersect", self._boolean_intersect)
toolbar.addSeparator()
toolbar.addAction("Fillet", self._apply_fillet)
toolbar.addAction("Chamfer", self._apply_chamfer)
def _create_dock_widgets(self) -> None:
browser_dock = QDockWidget("Browser", self)
self._browser = BrowserWidget()
browser_dock.setWidget(self._browser)
self.addDockWidget(Qt.LeftDockWidgetArea, browser_dock)
properties_dock = QDockWidget("Properties", self)
self._properties = PropertiesWidget()
properties_dock.setWidget(self._properties)
self.addDockWidget(Qt.RightDockWidgetArea, properties_dock)
def _create_central_widget(self) -> None:
central = QWidget()
layout = QVBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
self._view_container = QWidget()
self._renderer.initialize(self._view_container)
layout.addWidget(self._view_container)
self.setCentralWidget(central)
def _setup_connections(self) -> None:
self._properties._extrude_btn.clicked.connect(self._extrude_sketch)
self._properties._fillet_btn.clicked.connect(self._apply_fillet)
self._browser.item_selected.connect(self._on_item_selected)
def _create_initial_component(self) -> None:
comp = self._project.add_component()
self._browser.update_from_project(self._project)
def _new_project(self) -> None:
self._project = Project()
self._create_initial_component()
self._renderer.clear_scene()
self._renderer.render()
self.statusBar().showMessage("New project created")
def _open_project(self) -> None:
filepath, _ = QFileDialog.getOpenFileName(
self, "Open Project", "", "STEP Files (*.step *.stp)"
)
if filepath:
try:
geometry = self._kernel.import_step(filepath)
comp = self._project.add_component()
body = comp.add_body(Body(name="Imported", geometry=geometry))
self._render_body(body)
self._browser.update_from_project(self._project)
self.statusBar().showMessage(f"Opened: {filepath}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to open file: {e}")
def _export_step(self) -> None:
filepath, _ = QFileDialog.getSaveFileName(
self, "Export STEP", "", "STEP Files (*.step *.stp)"
)
if filepath:
if self._project.export_step(filepath):
self.statusBar().showMessage(f"Exported: {filepath}")
else:
QMessageBox.warning(self, "Export Failed", "No bodies to export")
def _export_iges(self) -> None:
filepath, _ = QFileDialog.getSaveFileName(
self, "Export IGES", "", "IGES Files (*.iges *.igs)"
)
if filepath:
if self._project.export_iges(filepath):
self.statusBar().showMessage(f"Exported: {filepath}")
else:
QMessageBox.warning(self, "Export Failed", "No bodies to export")
def _export_stl(self) -> None:
filepath, _ = QFileDialog.getSaveFileName(self, "Export STL", "", "STL Files (*.stl)")
if filepath:
if self._project.export_stl(filepath):
self.statusBar().showMessage(f"Exported: {filepath}")
else:
QMessageBox.warning(self, "Export Failed", "No bodies to export")
def _new_sketch(self) -> None:
comp = self._project.get_active_component()
if comp:
sketch = comp.add_sketch()
self._current_sketch = sketch
self._browser.update_from_project(self._project)
self.statusBar().showMessage(f"Created: {sketch.name}")
def _extrude_sketch(self) -> None:
comp = self._project.get_active_component()
if not comp:
return
sketch = comp.get_active_sketch()
if not sketch:
sketch = self._current_sketch
if not sketch or not sketch.occ_sketch:
QMessageBox.warning(self, "No Sketch", "Please create a sketch first")
return
sketch.solve()
geometry = sketch.get_geometry()
if not geometry:
QMessageBox.warning(self, "No Geometry", "Sketch has no valid geometry")
return
height = self._properties.get_extrude_height()
try:
body_geometry = self._kernel.extrude(geometry, height)
body = comp.add_body(
Body(
name=f"Extrusion {len(comp.bodies) + 1}",
geometry=body_geometry,
source_sketch=sketch,
source_operation="extrude",
)
)
self._render_body(body)
self._browser.update_from_project(self._project)
self.statusBar().showMessage(f"Extruded: {body.name}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Extrude failed: {e}")
def _revolve_sketch(self) -> None:
self.statusBar().showMessage("Revolve not yet implemented")
def _boolean_union(self) -> None:
self.statusBar().showMessage("Boolean union not yet implemented")
def _boolean_subtract(self) -> None:
self.statusBar().showMessage("Boolean subtract not yet implemented")
def _boolean_intersect(self) -> None:
self.statusBar().showMessage("Boolean intersect not yet implemented")
def _apply_fillet(self) -> None:
if not self._selected_body:
QMessageBox.warning(self, "No Selection", "Please select a body")
return
radius = self._properties.get_fillet_radius()
try:
self._selected_body.geometry = self._kernel.fillet(self._selected_body.geometry, radius)
self._render_body(self._selected_body)
self.statusBar().showMessage(f"Applied fillet: {radius}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Fillet failed: {e}")
def _apply_chamfer(self) -> None:
if not self._selected_body:
QMessageBox.warning(self, "No Selection", "Please select a body")
return
size = self._properties.get_fillet_radius()
try:
self._selected_body.geometry = self._kernel.chamfer(self._selected_body.geometry, size)
self._render_body(self._selected_body)
self.statusBar().showMessage(f"Applied chamfer: {size}")
except Exception as e:
QMessageBox.critical(self, "Error", f"Chamfer failed: {e}")
def _render_body(self, body: Body) -> None:
if not body.geometry:
return
vertices, faces = body.get_mesh(self._kernel)
if body.render_object:
self._renderer.update_mesh(body.render_object, vertices, faces)
else:
body.render_object = self._renderer.add_mesh(vertices, faces, body.color, body.name)
self._renderer.fit_camera()
self._renderer.render()
def _fit_view(self) -> None:
self._renderer.fit_camera()
self._renderer.render()
def _reset_view(self) -> None:
self._renderer.set_camera_position((100, 100, 100), (0, 0, 0))
self._renderer.render()
def _set_view(self, view: str) -> None:
positions = {
"iso": ((100, 100, 100), (0, 0, 0)),
"top": ((0, 0, 200), (0, 0, 0)),
"front": ((0, -200, 0), (0, 0, 0)),
"right": ((200, 0, 0), (0, 0, 0)),
}
if view in positions:
pos, target = positions[view]
self._renderer.set_camera_position(pos, target)
self._renderer.render()
def _on_item_selected(self, item_type: str, item_id: str) -> None:
if item_type == "body":
comp = self._project.get_active_component()
if comp and item_id in comp.bodies:
self._selected_body = comp.bodies[item_id]
self.statusBar().showMessage(f"Selected: {self._selected_body.name}")
elif item_type == "sketch":
comp = self._project.get_active_component()
if comp and item_id in comp.sketches:
self._current_sketch = comp.sketches[item_id]
comp.set_active_sketch(item_id)
self.statusBar().showMessage(f"Selected: {self._current_sketch.name}")
def _show_about(self) -> None:
QMessageBox.about(
self,
"About Fluency CAD",
"Fluency CAD 2.0\n\n"
"A parametric CAD application built on:\n"
"- OpenCASCADE Technology (OCCT)\n"
"- CadQuery Python bindings\n"
"- pygfx WebGPU renderer\n\n"
"Features:\n"
"- STEP/IGES import/export\n"
"- Parametric sketching\n"
"- Boolean operations\n"
"- Fillets and chamfers",
)
def main() -> int:
"""Application entry point."""
app = QApplication(sys.argv)
app.setStyle("Fusion")
window = MainWindow()
window.show()
return app.exec()
if __name__ == "__main__":
sys.exit(main())
+15
View File
@@ -0,0 +1,15 @@
"""Models module."""
from fluency.models.data_model import (
Project,
Component,
Sketch,
Body,
)
__all__ = [
"Project",
"Component",
"Sketch",
"Body",
]
+339
View File
@@ -0,0 +1,339 @@
"""
Data models for Fluency CAD.
This module defines the core data structures for the CAD application
including projects, components, sketches, and bodies.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from datetime import datetime
import uuid
import numpy as np
from fluency.geometry.base import (
Point2D,
Point3D,
GeometryObject,
SketchInterface,
)
from fluency.geometry_occ.kernel import OCGeometryKernel, OCCGeometryObject
from fluency.geometry_occ.sketch import OCCSketch
@dataclass
class Sketch:
"""
2D sketch with constraints.
A sketch contains 2D geometry on a workplane that can be
extruded or revolved to create 3D bodies.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Sketch"
workplane_origin: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
workplane_normal: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 1.0]))
workplane_x_dir: np.ndarray = field(default_factory=lambda: np.array([1.0, 0.0, 0.0]))
occ_sketch: Optional[OCCSketch] = field(default_factory=OCCSketch)
geometry: Optional[OCCGeometryObject] = None
is_solved: bool = False
is_fully_constrained: bool = False
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def add_point(self, x: float, y: float) -> Any:
"""Add a point to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_point(x, y)
return None
def add_line(self, start: Any, end: Any) -> Any:
"""Add a line to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_line(start, end)
return None
def add_circle(self, center: Any, radius: float) -> Any:
"""Add a circle to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_circle(center, radius)
return None
def add_rectangle(self, corner1: tuple, corner2: tuple) -> List[Any]:
"""Add a rectangle to the sketch."""
self.modified_at = datetime.now()
self.is_solved = False
if self.occ_sketch:
return self.occ_sketch.add_rectangle(corner1, corner2)
return []
def solve(self) -> bool:
"""Solve all constraints."""
if self.occ_sketch:
result = self.occ_sketch.solve()
self.is_solved = result
self.is_fully_constrained = self.occ_sketch.is_fully_constrained()
self.modified_at = datetime.now()
return result
return False
def get_geometry(self) -> Optional[GeometryObject]:
"""Get the solved geometry."""
if self.occ_sketch:
return self.occ_sketch.get_geometry()
return None
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points."""
if self.occ_sketch:
return self.occ_sketch.get_polygon_points()
return []
def clear(self) -> None:
"""Clear all geometry."""
if self.occ_sketch:
self.occ_sketch.clear()
self.geometry = None
self.is_solved = False
self.is_fully_constrained = False
self.modified_at = datetime.now()
@dataclass
class Body:
"""
3D solid body.
A body is created from a sketch through operations like
extrude, revolve, loft, or sweep.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Body"
geometry: Optional[OCCGeometryObject] = None
source_sketch: Optional[Sketch] = None
source_operation: str = "extrude"
position: np.ndarray = field(default_factory=lambda: np.array([0.0, 0.0, 0.0]))
rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
color: tuple = (0.2, 0.4, 0.8)
opacity: float = 1.0
visible: bool = True
render_object: Any = None
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def get_mesh(self, kernel: OCGeometryKernel, tolerance: float = 0.1) -> tuple:
"""Get mesh for rendering."""
if self.geometry and kernel:
return kernel.get_mesh(self.geometry, tolerance)
return np.array([]), np.array([])
def get_edges(self, kernel: OCGeometryKernel) -> tuple:
"""Get edges for wireframe rendering."""
if self.geometry and kernel:
return kernel.get_edges(self.geometry)
return np.array([]), np.array([])
@dataclass
class Component:
"""
Component containing sketches and bodies.
A component is a logical grouping of geometry, similar to
a part in a CAD system.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Component"
description: str = ""
sketches: Dict[str, Sketch] = field(default_factory=dict)
bodies: Dict[str, Body] = field(default_factory=dict)
active_sketch: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def add_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add a sketch to the component."""
if sketch is None:
sketch = Sketch(name=f"Sketch {len(self.sketches) + 1}")
self.sketches[sketch.id] = sketch
self.modified_at = datetime.now()
return sketch
def add_body(self, body: Optional[Body] = None) -> Body:
"""Add a body to the component."""
if body is None:
body = Body(name=f"Body {len(self.bodies) + 1}")
self.bodies[body.id] = body
self.modified_at = datetime.now()
return body
def remove_sketch(self, sketch_id: str) -> bool:
"""Remove a sketch from the component."""
if sketch_id in self.sketches:
del self.sketches[sketch_id]
if self.active_sketch == sketch_id:
self.active_sketch = None
self.modified_at = datetime.now()
return True
return False
def remove_body(self, body_id: str) -> bool:
"""Remove a body from the component."""
if body_id in self.bodies:
del self.bodies[body_id]
self.modified_at = datetime.now()
return True
return False
def get_active_sketch(self) -> Optional[Sketch]:
"""Get the currently active sketch."""
if self.active_sketch and self.active_sketch in self.sketches:
return self.sketches[self.active_sketch]
return None
def set_active_sketch(self, sketch_id: Optional[str]) -> None:
"""Set the active sketch."""
self.active_sketch = sketch_id
self.modified_at = datetime.now()
@dataclass
class Project:
"""
Top-level project container.
A project contains components and provides access to the
geometry kernel for operations.
"""
name: str = "Untitled Project"
description: str = ""
components: Dict[str, Component] = field(default_factory=dict)
active_component: Optional[str] = None
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
file_path: Optional[str] = None
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
component = Component(name=f"Component {len(self.components) + 1}")
self.components[component.id] = component
if self.active_component is None:
self.active_component = component.id
self.modified_at = datetime.now()
return component
def remove_component(self, component_id: str) -> bool:
"""Remove a component from the project."""
if component_id in self.components:
del self.components[component_id]
if self.active_component == component_id:
self.active_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_component(self) -> Optional[Component]:
"""Get the currently active component."""
if self.active_component and self.active_component in self.components:
return self.components[self.active_component]
return None
def set_active_component(self, component_id: Optional[str]) -> None:
"""Set the active component."""
self.active_component = component_id
self.modified_at = datetime.now()
def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP."""
all_bodies: List[OCCGeometryObject] = []
for comp in self.components.values():
for body in comp.bodies.values():
if body.visible and body.geometry:
all_bodies.append(body.geometry)
if not all_bodies:
return False
if len(all_bodies) == 1:
return self.kernel.export_step(all_bodies[0], filepath)
result = self.kernel.boolean_union(*all_bodies)
return self.kernel.export_step(result, filepath)
def export_iges(self, filepath: str) -> bool:
"""Export all visible bodies to IGES."""
all_bodies: List[OCCGeometryObject] = []
for comp in self.components.values():
for body in comp.bodies.values():
if body.visible and body.geometry:
all_bodies.append(body.geometry)
if not all_bodies:
return False
if len(all_bodies) == 1:
return self.kernel.export_iges(all_bodies[0], filepath)
result = self.kernel.boolean_union(*all_bodies)
return self.kernel.export_iges(result, filepath)
def export_stl(self, filepath: str, tolerance: float = 0.1) -> bool:
"""Export all visible bodies to STL."""
all_bodies: List[OCCGeometryObject] = []
for comp in self.components.values():
for body in comp.bodies.values():
if body.visible and body.geometry:
all_bodies.append(body.geometry)
if not all_bodies:
return False
if len(all_bodies) == 1:
return self.kernel.export_stl(all_bodies[0], filepath, tolerance)
result = self.kernel.boolean_union(*all_bodies)
return self.kernel.export_stl(result, filepath, tolerance)
def get_all_bodies(self) -> List[Body]:
"""Get all bodies from all components."""
bodies: List[Body] = []
for comp in self.components.values():
bodies.extend(comp.bodies.values())
return bodies
def get_all_sketches(self) -> List[Sketch]:
"""Get all sketches from all components."""
sketches: List[Sketch] = []
for comp in self.components.values():
sketches.extend(comp.sketches.values())
return sketches
+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
+3
View File
@@ -0,0 +1,3 @@
"""Utilities module."""
__all__ = []
+3
View File
@@ -0,0 +1,3 @@
"""Widgets module."""
__all__ = []