Files
fluencyCAD/src/fluency/models/data_model.py
T
2026-07-05 19:36:27 +02:00

773 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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