""" 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, Tuple 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 Workplane: """ An independent working plane (datum plane) not tied to a face. Workplanes can be created at any time and serve as the foundation for sketching and subsequent 3D operations (extrude, cut, revolve, etc.). They are visible in the 3D view as a semi-transparent reference grid. """ id: str = field(default_factory=lambda: str(uuid.uuid4())) name: str = "Untitled Workplane" origin: Tuple[float, float, float] = (0.0, 0.0, 0.0) normal: Tuple[float, float, float] = (0.0, 0.0, 1.0) x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0) # OCC AIS shape (visual plane) object id in the renderer render_object: Any = None visible: bool = True created_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now) def __post_init__(self): # Normalise normal and x_dir on construction. import numpy as np n = np.asarray(self.normal, dtype=float) n = n / np.linalg.norm(n) x = np.asarray(self.x_dir, dtype=float) # Remove any component of x along n, then renormalise. x = x - np.dot(x, n) * n x_norm = np.linalg.norm(x) if x_norm < 1e-9: fallback = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) x = fallback - np.dot(fallback, n) * n x_norm = np.linalg.norm(x) x = x / x_norm y = np.cross(n, x) y = y / np.linalg.norm(y) self.normal = tuple(float(v) for v in n) self.x_dir = tuple(float(v) for v in x) self._y_dir = tuple(float(v) for v in y) @property def y_dir(self) -> Tuple[float, float, float]: """Derived in-plane Y axis (normal × x_dir).""" return self._y_dir def uv_to_world(self, u: float, v: float) -> Tuple[float, float, float]: """Map a UV point to 3D world coordinates on this plane.""" ox, oy, oz = self.origin xx, xy, xz = self.x_dir yx, yy, yz = self._y_dir return ( ox + u * xx + v * yx, oy + u * xy + v * yy, oz + u * xz + v * yz, ) def world_to_uv(self, p: Tuple[float, float, float]) -> Tuple[float, float]: """Map a 3D world point to UV coordinates on this plane.""" import numpy as np ox, oy, oz = self.origin v = np.array([p[0] - ox, p[1] - oy, p[2] - oz]) xd = np.array(self.x_dir, dtype=float) yd = np.array(self._y_dir, dtype=float) return (float(np.dot(v, xd)), float(np.dot(v, yd))) @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 set_workplane( self, origin: Tuple[float, float, float], normal: Tuple[float, float, float], x_dir: Tuple[float, float, float], ) -> None: """Set this sketch's 3D workplane and sync it to the OCC sketch. Call this when the sketch is placed on a face/datum plane. UV coordinates are unchanged; only their world mapping moves. """ self.workplane_origin = np.asarray(origin, dtype=float) self.workplane_normal = np.asarray(normal, dtype=float) self.workplane_x_dir = np.asarray(x_dir, dtype=float) self.apply_workplane() self.modified_at = datetime.now() def apply_workplane(self) -> None: """Push the stored workplane fields into the underlying OCCSketch.""" if self.occ_sketch is not None: self.occ_sketch.set_workplane( tuple(self.workplane_origin.tolist()), tuple(self.workplane_normal.tolist()), tuple(self.workplane_x_dir.tolist()), ) 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) workplanes: Dict[str, Workplane] = field(default_factory=dict) active_sketch: Optional[str] = None active_workplane: Optional[str] = None created_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now) def add_workplane(self, workplane: Optional[Workplane] = None) -> Workplane: """Add an independent workplane to the component.""" if workplane is None: workplane = Workplane(name=f"Workplane {len(self.workplanes) + 1}") self.workplanes[workplane.id] = workplane if self.active_workplane is None: self.active_workplane = workplane.id self.modified_at = datetime.now() return workplane def remove_workplane(self, wp_id: str) -> bool: """Remove a workplane from the component.""" if wp_id in self.workplanes: del self.workplanes[wp_id] if self.active_workplane == wp_id: self.active_workplane = next(iter(self.workplanes.keys()), None) self.modified_at = datetime.now() return True return False 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 Connector: """ A connection point on an assembly component instance. Stores the position and orientation of a connection point (e.g. a hole center, face midpoint, or edge point) that will later be used by the SolveSpace solver to mate components. The *normal* defines the connection axis direction (e.g. the hole axis for a screw connection). """ id: str = field(default_factory=lambda: str(uuid.uuid4())) name: str = "Untitled Connector" # 3D position of the connection point (world coords). position: Tuple[float, float, float] = (0.0, 0.0, 0.0) # Normal direction of the connection (e.g. hole axis). normal: Tuple[float, float, float] = (0.0, 0.0, 1.0) # In-plane X direction for defining the reference frame. x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0) # Rotation around the normal axis (degrees). axis_rotation: float = 0.0 # Offset distance along the normal. offset: float = 0.0 # Which AssemblyComponent this connector belongs to. assembly_component_id: str = "" # Which body/face this connector was placed on (renderer obj_id). source_obj_id: str = "" # --- Rigid-group pairing (set when two connectors are mated) --- # The id of the partner AssemblyComponent this connector is mated to. # The FIRST-picked component is the grounded reference of the pair; # 'is_grounded' marks that side so the move handler knows which half # is the fixed frame of the rigid group. partner_ac_id: Optional[str] = None # The id of the partner Connector on the partner component. partner_connector_id: Optional[str] = None # True on the first-picked (grounded) connector of a mated pair. is_grounded: bool = False created_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now) @dataclass class AssemblyComponent: """ An instance of a component within an assembly. References a component in the project and stores its relative position and rotation for placement within the assembly. Holds connectors that define connection points for the SolveSpace solver. """ id: str = field(default_factory=lambda: str(uuid.uuid4())) component_id: str = "" name: str = "Untitled Instance" # Position and orientation relative to the assembly origin. 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)) # Connectors defined on this component instance. connectors: Dict[str, Connector] = field(default_factory=dict) created_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now) def add_connector( self, position: Tuple[float, float, float], normal: Tuple[float, float, float], x_dir: Tuple[float, float, float], source_obj_id: str = "", name: Optional[str] = None, ) -> Connector: """Add a connector to this component instance.""" conn = Connector( name=name or f"Connector {len(self.connectors) + 1}", position=position, normal=normal, x_dir=x_dir, assembly_component_id=self.id, source_obj_id=source_obj_id, ) self.connectors[conn.id] = conn self.modified_at = datetime.now() return conn def remove_connector(self, connector_id: str) -> bool: """Remove a connector from this component instance.""" if connector_id in self.connectors: del self.connectors[connector_id] self.modified_at = datetime.now() return True return False @dataclass class AssemblyConnection: """A mated connector pair linking two AssemblyComponents. Records which component is the grounded reference (``first_ac_id``) and which was solved against it (``second_ac_id``), plus the partner connector ids so the linkage can be followed / removed symmetrically. Used by the assembly-move handler to propagate translations across the rigid group. """ id: str = field(default_factory=lambda: str(uuid.uuid4())) first_ac_id: str = "" # grounded reference side second_ac_id: str = "" # solved side first_connector_id: Optional[str] = None second_connector_id: Optional[str] = None created_at: datetime = field(default_factory=datetime.now) @dataclass class Assembly: """ An assembly of multiple component instances. An assembly groups component instances with relative positions, ready for constraint solving and joint definition between them. Components can be instanced multiple times, each at a different position and rotation. """ id: str = field(default_factory=lambda: str(uuid.uuid4())) name: str = "Untitled Assembly" components: Dict[str, AssemblyComponent] = field(default_factory=dict) active_assembly_component: Optional[str] = None # Mated connector pairs — each entry links two AssemblyComponents so the # assembly-move handler can propagate rigid-group translations. The # 'first_ac_id' side is the grounded reference of the pair. connections: List["AssemblyConnection"] = field(default_factory=list) created_at: datetime = field(default_factory=datetime.now) modified_at: datetime = field(default_factory=datetime.now) def add_connection(self, first_ac_id: str, second_ac_id: str) -> "AssemblyConnection": """Record a mated connector pair between two component instances. The first-picked component (``first_ac_id``) is treated as the grounded reference of the pair. Returns the AssemblyConnection for further bookkeeping (e.g. attaching partner connector ids). """ conn = AssemblyConnection( first_ac_id=first_ac_id, second_ac_id=second_ac_id, ) self.connections.append(conn) self.modified_at = datetime.now() return conn def remove_connections_for(self, ac_id: str) -> None: """Drop every connection that involves *ac_id* (e.g. on removal).""" self.connections = [ c for c in self.connections if c.first_ac_id != ac_id and c.second_ac_id != ac_id ] def get_rigid_group(self, ac_id: str) -> List[str]: """Return ids of all components rigidly linked to *ac_id* (BFS). Includes *ac_id* itself. Two components are linked when a mated connector pair (in ``connections``) joins them; linkage is transitive, so the whole connected subgraph forms one rigid group. """ if ac_id not in self.components: return [] # Build adjacency from the connection list. adj: Dict[str, List[str]] = {} for c in self.connections: adj.setdefault(c.first_ac_id, []).append(c.second_ac_id) adj.setdefault(c.second_ac_id, []).append(c.first_ac_id) seen: List[str] = [] queue: List[str] = [ac_id] while queue: cur = queue.pop(0) if cur in seen: continue seen.append(cur) for nb in adj.get(cur, []): if nb not in seen: queue.append(nb) return seen def is_grounded_reference(self, ac_id: str) -> bool: """True if *ac_id* is the grounded (first-picked) side of any pair.""" return any(c.first_ac_id == ac_id for c in self.connections) def add_component_instance( self, component_id: str, name: Optional[str] = None ) -> AssemblyComponent: """Add a component instance to the assembly. Returns the newly created AssemblyComponent. The same component can be added multiple times (multiple instances). """ ac = AssemblyComponent( component_id=component_id, name=name or f"Instance {len(self.components) + 1}", ) self.components[ac.id] = ac if self.active_assembly_component is None: self.active_assembly_component = ac.id self.modified_at = datetime.now() return ac def remove_component_instance(self, assembly_component_id: str) -> bool: """Remove a component instance from the assembly.""" if assembly_component_id in self.components: del self.components[assembly_component_id] # Also drop any mated-connector links that referenced this # instance — otherwise stale connection edges would remain in # the rigid-group graph and point at a missing component. self.remove_connections_for(assembly_component_id) if self.active_assembly_component == assembly_component_id: self.active_assembly_component = next( iter(self.components.keys()), None ) self.modified_at = datetime.now() return True return False def get_active_instance(self) -> Optional[AssemblyComponent]: """Get the currently active assembly component instance.""" if ( self.active_assembly_component and self.active_assembly_component in self.components ): return self.components[self.active_assembly_component] return None @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 assemblies: Dict[str, Assembly] = field(default_factory=dict) active_assembly: 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 # ── Component helpers ── 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() # ── Assembly helpers ── def add_assembly(self, assembly: Optional[Assembly] = None) -> Assembly: """Add an assembly to the project.""" if assembly is None: assembly = Assembly(name=f"Assembly {len(self.assemblies) + 1}") self.assemblies[assembly.id] = assembly if self.active_assembly is None: self.active_assembly = assembly.id self.modified_at = datetime.now() return assembly def remove_assembly(self, assembly_id: str) -> bool: """Remove an assembly from the project.""" if assembly_id in self.assemblies: del self.assemblies[assembly_id] if self.active_assembly == assembly_id: self.active_assembly = next(iter(self.assemblies.keys()), None) self.modified_at = datetime.now() return True return False def get_active_assembly(self) -> Optional[Assembly]: """Get the currently active assembly.""" if self.active_assembly and self.active_assembly in self.assemblies: return self.assemblies[self.active_assembly] return None def set_active_assembly(self, assembly_id: Optional[str]) -> None: """Set the active assembly.""" self.active_assembly = assembly_id self.modified_at = datetime.now() def get_component_by_id(self, component_id: str) -> Optional[Component]: """Look up a component by id across all project components.""" return self.components.get(component_id) 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