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:
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Models module."""
|
||||
|
||||
from fluency.models.data_model import (
|
||||
Project,
|
||||
Component,
|
||||
Sketch,
|
||||
Body,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Project",
|
||||
"Component",
|
||||
"Sketch",
|
||||
"Body",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Utilities module."""
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Widgets module."""
|
||||
|
||||
__all__ = []
|
||||
Reference in New Issue
Block a user