773 lines
28 KiB
Python
773 lines
28 KiB
Python
"""
|
||
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
|