diff --git a/pyproject.toml b/pyproject.toml index ae00638..84737a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dev = [ ] [tool.setuptools.packages.find] -include = ["sketch_canonical*"] +include = ["sketch_canonical*", "sketch_adapter_freecad*", "sketch_adapter_fusion*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/sketch_adapter_freecad/__init__.py b/sketch_adapter_freecad/__init__.py index 894e0a9..4198f61 100644 --- a/sketch_adapter_freecad/__init__.py +++ b/sketch_adapter_freecad/__init__.py @@ -16,7 +16,7 @@ When FreeCAD is not available, a MockFreeCADAdapter is provided for testing. """ -from .adapter import FreeCADAdapter, FREECAD_AVAILABLE +from .adapter import FREECAD_AVAILABLE, FreeCADAdapter from .vertex_map import VertexMap, get_vertex_index __all__ = [ diff --git a/sketch_adapter_freecad/adapter.py b/sketch_adapter_freecad/adapter.py index 88b8e78..bfe1a28 100644 --- a/sketch_adapter_freecad/adapter.py +++ b/sketch_adapter_freecad/adapter.py @@ -12,31 +12,30 @@ """ import math -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any from sketch_canonical import ( - SketchBackendAdapter, - SketchDocument, - SolverStatus, - SketchPrimitive, - SketchConstraint, - Point2D, - PointType, - PointRef, - Line, Arc, Circle, - Point, - Spline, + ConstraintError, ConstraintType, - ConstraintStatus, + ExportError, GeometryError, - ConstraintError, + Line, + Point, + Point2D, + PointRef, + PointType, + SketchBackendAdapter, + SketchConstraint, SketchCreationError, - ExportError, + SketchDocument, + SketchPrimitive, + SolverStatus, + Spline, ) -from .vertex_map import VertexMap, get_vertex_index, get_point_type_from_vertex +from .vertex_map import VertexMap, get_point_type_from_vertex, get_vertex_index # Try to import FreeCAD modules FREECAD_AVAILABLE = False @@ -79,7 +78,7 @@ class FreeCADAdapter(SketchBackendAdapter): ConstraintType.CONCENTRIC: 'Coincident', # Concentric uses Coincident on centers } - def __init__(self, document: Optional[Any] = None): + def __init__(self, document: Any | None = None): """ Initialize the FreeCAD adapter. @@ -94,11 +93,11 @@ def __init__(self, document: Optional[Any] = None): self._document = document self._sketch = None - self._sketch_doc: Optional[SketchDocument] = None + self._sketch_doc: SketchDocument | None = None # ID to FreeCAD geometry index mapping - self._id_to_index: Dict[str, int] = {} - self._index_to_id: Dict[int, str] = {} + self._id_to_index: dict[str, int] = {} + self._index_to_id: dict[int, str] = {} def _get_document(self) -> Any: """Get the FreeCAD document, creating one if needed.""" @@ -114,7 +113,7 @@ def _get_active_sketch(self) -> Any: raise SketchCreationError("No active sketch. Call create_sketch() first.") return self._sketch - def create_sketch(self, name: str, plane: Optional[Any] = None) -> None: + def create_sketch(self, name: str, plane: Any | None = None) -> None: """ Create a new empty sketch in FreeCAD. @@ -298,7 +297,7 @@ def _add_spline(self, sketch: Any, spline: Spline) -> int: return sketch.addGeometry(bspline, spline.construction) - def _extract_knots_and_mults(self, knots: List[float]) -> Tuple[List[float], List[int]]: + def _extract_knots_and_mults(self, knots: list[float]) -> tuple[list[float], list[int]]: """ Extract unique knots and multiplicities from an expanded knot vector. @@ -333,7 +332,7 @@ def _extract_knots_and_mults(self, knots: List[float]) -> Tuple[List[float], Lis return unique_knots, mults - def _compute_multiplicities(self, spline: Spline) -> List[int]: + def _compute_multiplicities(self, spline: Spline) -> list[int]: """Compute knot multiplicities from the knot vector.""" _, mults = self._extract_knots_and_mults(spline.knots) return mults @@ -393,7 +392,7 @@ def add_constraint(self, constraint: SketchConstraint) -> bool: except Exception as e: raise ConstraintError(f"Failed to add constraint: {e}") from e - def _point_ref_to_freecad(self, ref: PointRef) -> Tuple[int, int]: + def _point_ref_to_freecad(self, ref: PointRef) -> tuple[int, int]: """ Convert a PointRef to FreeCAD (geometry_index, vertex_index). @@ -440,7 +439,7 @@ def _infer_vertex_index(self, geo: Any, point_type: PointType) -> int: else: return 1 - def _get_element_index(self, ref: Union[str, PointRef]) -> int: + def _get_element_index(self, ref: str | PointRef) -> int: """Get the geometry index for an element reference.""" if isinstance(ref, PointRef): element_id = ref.element_id @@ -607,7 +606,7 @@ def _add_symmetric(self, sketch: Any, constraint: SketchConstraint) -> None: 'Symmetric', idx1, idx2, axis_idx )) - def get_solver_status(self) -> Tuple[SolverStatus, int]: + def get_solver_status(self) -> tuple[SolverStatus, int]: """Get the constraint solver status.""" sketch = self._get_active_sketch() @@ -643,8 +642,8 @@ def capture_image(self, width: int, height: int) -> bytes: view.setImageSize(width, height) # Capture to temp file and read bytes - import tempfile import os + import tempfile with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: temp_path = f.name @@ -658,9 +657,9 @@ def capture_image(self, width: int, height: int) -> bytes: return data except ImportError: - raise ExportError("FreeCADGui not available for image capture") + raise ExportError("FreeCADGui not available for image capture") from None except Exception as e: - raise ExportError(f"Failed to capture image: {e}") + raise ExportError(f"Failed to capture image: {e}") from e def close_sketch(self) -> None: """Close the current sketch.""" @@ -671,7 +670,7 @@ def close_sketch(self) -> None: self._id_to_index.clear() self._index_to_id.clear() - def get_element_by_id(self, element_id: str) -> Optional[Any]: + def get_element_by_id(self, element_id: str) -> Any | None: """Get the FreeCAD geometry object for a canonical element ID.""" if element_id not in self._id_to_index: return None @@ -694,7 +693,7 @@ def supports_feature(self, feature: str) -> bool: # Export helpers - def _geometry_to_primitive(self, geo: Any, index: int) -> Optional[SketchPrimitive]: + def _geometry_to_primitive(self, geo: Any, index: int) -> SketchPrimitive | None: """Convert FreeCAD geometry to canonical primitive.""" geo_type = type(geo).__name__ # Construction flag is stored on the sketch, not the geometry @@ -751,7 +750,7 @@ def _geometry_to_primitive(self, geo: Any, index: int) -> Optional[SketchPrimiti # Expand knots with multiplicities mults = geo.getMultiplicities() full_knots = [] - for k, m in zip(knots, mults): + for k, m in zip(knots, mults, strict=False): full_knots.extend([k] * m) return Spline( @@ -765,7 +764,7 @@ def _geometry_to_primitive(self, geo: Any, index: int) -> Optional[SketchPrimiti return None - def _fc_constraint_to_canonical(self, fc_constraint: Any) -> Optional[SketchConstraint]: + def _fc_constraint_to_canonical(self, fc_constraint: Any) -> SketchConstraint | None: """Convert FreeCAD constraint to canonical form.""" fc_type = fc_constraint.Type @@ -814,7 +813,7 @@ def _fc_constraint_to_canonical(self, fc_constraint: Any) -> Optional[SketchCons def _extract_constraint_references( self, fc_constraint: Any, constraint_type: ConstraintType - ) -> Optional[List[Union[str, PointRef]]]: + ) -> list[str | PointRef] | None: """Extract references from a FreeCAD constraint.""" first = fc_constraint.First second = fc_constraint.Second if hasattr(fc_constraint, 'Second') else -1 @@ -824,10 +823,10 @@ def _extract_constraint_references( second_pos = fc_constraint.SecondPos if hasattr(fc_constraint, 'SecondPos') else 0 # Convert geometry indices to element IDs - def idx_to_id(idx: int) -> Optional[str]: + def idx_to_id(idx: int) -> str | None: return self._index_to_id.get(idx) - def idx_to_point_ref(idx: int, pos: int) -> Optional[PointRef]: + def idx_to_point_ref(idx: int, pos: int) -> PointRef | None: elem_id = idx_to_id(idx) if elem_id is None: return None diff --git a/sketch_adapter_freecad/vertex_map.py b/sketch_adapter_freecad/vertex_map.py index cbbb5f6..927cea9 100644 --- a/sketch_adapter_freecad/vertex_map.py +++ b/sketch_adapter_freecad/vertex_map.py @@ -11,9 +11,8 @@ """ from dataclasses import dataclass -from typing import Optional -from sketch_canonical import PointType, Line, Arc, Circle, Point, Spline +from sketch_canonical import Arc, Circle, Line, Point, PointType, Spline @dataclass @@ -49,7 +48,7 @@ class VertexMap: EXTERNAL_GEO_BASE = -2 -def get_vertex_index(primitive_type: type, point_type: PointType) -> Optional[int]: +def get_vertex_index(primitive_type: type, point_type: PointType) -> int | None: """ Get the FreeCAD vertex index for a point type on a primitive. @@ -90,7 +89,7 @@ def get_vertex_index(primitive_type: type, point_type: PointType) -> Optional[in return mapping.get(point_type) -def get_point_type_from_vertex(primitive_type: type, vertex_index: int) -> Optional[PointType]: +def get_point_type_from_vertex(primitive_type: type, vertex_index: int) -> PointType | None: """ Get the canonical point type from a FreeCAD vertex index. diff --git a/sketch_adapter_fusion/__init__.py b/sketch_adapter_fusion/__init__.py new file mode 100644 index 0000000..0df3fcd --- /dev/null +++ b/sketch_adapter_fusion/__init__.py @@ -0,0 +1,43 @@ +"""Fusion 360 adapter for canonical sketch representation. + +This module provides the FusionAdapter class for translating between +the canonical sketch representation and Autodesk Fusion 360's native +sketch API. + +Example usage (within Fusion 360): + + from sketch_adapter_fusion import FusionAdapter + from sketch_canonical.document import SketchDocument + from sketch_canonical.primitives import Line + from sketch_canonical.types import Point2D + + # Create adapter (requires running within Fusion 360) + adapter = FusionAdapter() + + # Create a new sketch + adapter.create_sketch("MySketch", plane="XY") + + # Add geometry + line = Line(start=Point2D(0, 0), end=Point2D(100, 0)) + adapter.add_primitive(line) + + # Or load an entire SketchDocument + doc = SketchDocument(name="ImportedSketch") + # ... add primitives and constraints to doc ... + adapter.load_sketch(doc) + + # Export back to canonical format + exported_doc = adapter.export_sketch() + +Note: This adapter must be run within Fusion 360's Python environment +where the 'adsk' module is available. +""" + +from .adapter import FusionAdapter +from .vertex_map import VertexMap, get_point_from_sketch_entity + +__all__ = [ + "FusionAdapter", + "VertexMap", + "get_point_from_sketch_entity", +] diff --git a/sketch_adapter_fusion/adapter.py b/sketch_adapter_fusion/adapter.py new file mode 100644 index 0000000..2790172 --- /dev/null +++ b/sketch_adapter_fusion/adapter.py @@ -0,0 +1,1654 @@ +"""Fusion 360 adapter for canonical sketch representation. + +This module provides the FusionAdapter class that implements the +SketchBackendAdapter interface for Autodesk Fusion 360. + +Note: Fusion 360 internally uses centimeters, while the canonical format +uses millimeters. This adapter handles the conversion automatically. +""" + +import math +from typing import Any + +from sketch_canonical.adapter import ( + AdapterError, + ConstraintError, + ExportError, + GeometryError, + SketchBackendAdapter, + SketchCreationError, +) +from sketch_canonical.constraints import ConstraintType, SketchConstraint +from sketch_canonical.document import SketchDocument, SolverStatus +from sketch_canonical.primitives import Arc, Circle, Line, Point, SketchPrimitive, Spline +from sketch_canonical.types import PointRef, PointType + +from .vertex_map import VertexMap + +# Fusion 360 uses centimeters internally, canonical format uses millimeters +MM_TO_CM = 0.1 +CM_TO_MM = 10.0 + + +class FusionAdapter(SketchBackendAdapter): + """Fusion 360 implementation of SketchBackendAdapter. + + This adapter translates between the canonical sketch representation + and Fusion 360's native sketch API. It requires Fusion 360 to be + running and accessible via the adsk module. + + Attributes: + _app: Fusion 360 Application object + _design: Active Fusion design + _sketch: Current active sketch + _id_to_entity: Mapping from canonical IDs to Fusion sketch entities + _entity_to_id: Mapping from Fusion entities to canonical IDs + """ + + def __init__(self, document=None): + """Initialize the Fusion 360 adapter. + + Args: + document: Optional Fusion 360 document. If None, uses active document. + + Raises: + ImportError: If Fusion 360 API is not available + AdapterError: If no active design is found + """ + try: + import adsk.core + import adsk.fusion + self._adsk_core = adsk.core + self._adsk_fusion = adsk.fusion + except ImportError as e: + raise ImportError( + "Fusion 360 API not available. This adapter must be run within Fusion 360." + ) from e + + self._app = adsk.core.Application.get() + if not self._app: + raise AdapterError("Could not get Fusion 360 application instance") + + if document is not None: + self._document = document + else: + self._document = self._app.activeDocument + + if self._document is None: + raise AdapterError("No active Fusion 360 document") + + self._design = adsk.fusion.Design.cast(self._app.activeProduct) + if not self._design: + raise AdapterError("No active Fusion 360 design") + + self._sketch = None + self._id_to_entity: dict[str, Any] = {} + self._entity_to_id: dict[Any, str] = {} + self._fixed_entity_tokens: set[str] = set() # Track entities with Fixed constraints + + def create_sketch(self, name: str, plane=None) -> None: + """Create a new sketch in Fusion 360. + + Args: + name: Name for the new sketch + plane: Optional plane specification. Can be: + - None: Uses XY construction plane + - "XY", "XZ", "YZ": Standard construction planes + - A Fusion 360 ConstructionPlane or BRepFace object + + Raises: + SketchCreationError: If sketch creation fails + """ + try: + root_comp = self._design.rootComponent + sketches = root_comp.sketches + + # Determine the plane to use + if plane is None or plane == "XY": + sketch_plane = root_comp.xYConstructionPlane + elif plane == "XZ": + sketch_plane = root_comp.xZConstructionPlane + elif plane == "YZ": + sketch_plane = root_comp.yZConstructionPlane + else: + sketch_plane = plane + + self._sketch = sketches.add(sketch_plane) + self._sketch.name = name + + # Clear mappings for new sketch + self._id_to_entity.clear() + self._entity_to_id.clear() + self._fixed_entity_tokens.clear() + + except Exception as e: + raise SketchCreationError(f"Failed to create sketch: {e}") from e + + def load_sketch(self, sketch: SketchDocument) -> None: + """Load a SketchDocument into a new Fusion 360 sketch. + + Creates a new sketch and populates it with the primitives and + constraints from the provided SketchDocument. + + Args: + sketch: The SketchDocument to load + + Raises: + SketchCreationError: If sketch creation fails + GeometryError: If geometry creation fails + ConstraintError: If constraint creation fails + """ + # Create the sketch + self.create_sketch(sketch.name) + + # Add all primitives + for prim_id, primitive in sketch.primitives.items(): + self.add_primitive(primitive) + + # Add all constraints + for constraint in sketch.constraints: + try: + self.add_constraint(constraint) + except ConstraintError: + # Log but continue - some constraints may not be supported + pass + + def export_sketch(self) -> SketchDocument: + """Export the current Fusion 360 sketch to a SketchDocument. + + Returns: + A SketchDocument representing the current sketch + + Raises: + ExportError: If export fails or no sketch is active + """ + if not self._sketch: + raise ExportError("No active sketch to export") + + try: + doc = SketchDocument(name=self._sketch.name) + + # Export all geometry + self._export_lines(doc) + self._export_arcs(doc) + self._export_circles(doc) + self._export_points(doc) + self._export_splines(doc) + + # Export constraints + self._export_geometric_constraints(doc) + self._export_dimensional_constraints(doc) + + # Update solver status + status, dof = self.get_solver_status() + doc.solver_status = status + doc.degrees_of_freedom = dof + + return doc + + except Exception as e: + raise ExportError(f"Failed to export sketch: {e}") from e + + def add_primitive(self, primitive: SketchPrimitive) -> Any: + """Add a primitive to the current Fusion 360 sketch. + + Args: + primitive: The primitive to add + + Returns: + The created Fusion 360 sketch entity + + Raises: + GeometryError: If the primitive cannot be added + """ + if not self._sketch: + raise GeometryError("No active sketch") + + try: + if isinstance(primitive, Line): + entity = self._add_line(primitive) + elif isinstance(primitive, Arc): + entity = self._add_arc(primitive) + elif isinstance(primitive, Circle): + entity = self._add_circle(primitive) + elif isinstance(primitive, Point): + entity = self._add_point(primitive) + elif isinstance(primitive, Spline): + entity = self._add_spline(primitive) + else: + raise GeometryError(f"Unsupported primitive type: {type(primitive)}") + + # Set construction geometry flag if needed + if primitive.construction and hasattr(entity, "isConstruction"): + entity.isConstruction = True + + # Store mapping + self._id_to_entity[primitive.id] = entity + self._entity_to_id[entity.entityToken] = primitive.id + + return entity + + except Exception as e: + if isinstance(e, GeometryError): + raise + raise GeometryError(f"Failed to add primitive {primitive.id}: {e}") from e + + def _add_line(self, line: Line) -> Any: + """Add a line to the sketch.""" + lines = self._sketch.sketchCurves.sketchLines + + start_pt = self._point2d_to_point3d(line.start) + end_pt = self._point2d_to_point3d(line.end) + + return lines.addByTwoPoints(start_pt, end_pt) + + def _add_arc(self, arc: Arc) -> Any: + """Add an arc to the sketch. + + Uses three-point construction for reliable direction representation. + """ + arcs = self._sketch.sketchCurves.sketchArcs + + # Get three points for arc construction + start, mid, end = arc.to_three_point() + start_pt = self._point2d_to_point3d(start) + mid_pt = self._point2d_to_point3d(mid) + end_pt = self._point2d_to_point3d(end) + + return arcs.addByThreePoints(start_pt, mid_pt, end_pt) + + def _add_circle(self, circle: Circle) -> Any: + """Add a circle to the sketch.""" + circles = self._sketch.sketchCurves.sketchCircles + + center_pt = self._point2d_to_point3d(circle.center) + radius_cm = circle.radius * MM_TO_CM + + return circles.addByCenterRadius(center_pt, radius_cm) + + def _add_point(self, point: Point) -> Any: + """Add a sketch point.""" + points = self._sketch.sketchPoints + + pt = self._point2d_to_point3d(point.position) + + return points.add(pt) + + def _add_spline(self, spline: Spline) -> Any: + """Add a spline to the sketch. + + Fusion 360 supports both fitted splines (through points) and + control-point-based splines via NurbsCurve3D. We use the NURBS + approach for precise control point specification. + """ + # Create a list of Point3D from the spline control points + control_points = [] + for pole in spline.control_points: + control_points.append(self._point2d_to_point3d(pole)) + + # Extract knot vector and weights + knots = list(spline.knots) + degree = spline.degree + weights = list(spline.weights) if spline.weights else [1.0] * len(spline.control_points) + + # Create the NURBS curve (transient geometry) + # NurbsCurve3D methods expect Python lists, not ObjectCollections + if spline.weights: + nurbs_curve = self._adsk_core.NurbsCurve3D.createRational( + control_points, + degree, + knots, + weights, + spline.periodic + ) + else: + nurbs_curve = self._adsk_core.NurbsCurve3D.createNonRational( + control_points, + degree, + knots, + spline.periodic + ) + + # Add as a fitted spline using the NURBS curve + # Note: addByNurbsCurve is on sketchFittedSplines collection + splines = self._sketch.sketchCurves.sketchFittedSplines + return splines.addByNurbsCurve(nurbs_curve) + + def add_constraint(self, constraint: SketchConstraint) -> bool: + """Add a constraint to the current sketch. + + Args: + constraint: The constraint to add + + Returns: + True if the constraint was added successfully + + Raises: + ConstraintError: If the constraint cannot be added + """ + if not self._sketch: + raise ConstraintError("No active sketch") + + try: + ctype = constraint.constraint_type + refs = constraint.references + value = constraint.value + + # Geometric constraints + if ctype == ConstraintType.COINCIDENT: + return self._add_coincident(refs) + elif ctype == ConstraintType.HORIZONTAL: + return self._add_horizontal(refs) + elif ctype == ConstraintType.VERTICAL: + return self._add_vertical(refs) + elif ctype == ConstraintType.PARALLEL: + return self._add_parallel(refs) + elif ctype == ConstraintType.PERPENDICULAR: + return self._add_perpendicular(refs) + elif ctype == ConstraintType.TANGENT: + return self._add_tangent(refs) + elif ctype == ConstraintType.EQUAL: + return self._add_equal(refs) + elif ctype == ConstraintType.CONCENTRIC: + return self._add_concentric(refs) + elif ctype == ConstraintType.COLLINEAR: + return self._add_collinear(refs) + elif ctype == ConstraintType.FIXED: + return self._add_fixed(refs) + elif ctype == ConstraintType.SYMMETRIC: + return self._add_symmetric(refs) + elif ctype == ConstraintType.MIDPOINT: + return self._add_midpoint(refs) + + # Dimensional constraints + elif ctype == ConstraintType.DISTANCE: + return self._add_distance(refs, value) + elif ctype == ConstraintType.DISTANCE_X: + return self._add_distance_x(refs, value) + elif ctype == ConstraintType.DISTANCE_Y: + return self._add_distance_y(refs, value) + elif ctype == ConstraintType.LENGTH: + return self._add_length(refs, value) + elif ctype == ConstraintType.RADIUS: + return self._add_radius(refs, value) + elif ctype == ConstraintType.DIAMETER: + return self._add_diameter(refs, value) + elif ctype == ConstraintType.ANGLE: + return self._add_angle(refs, value) + else: + raise ConstraintError(f"Unsupported constraint type: {ctype}") + + except Exception as e: + if isinstance(e, ConstraintError): + raise + raise ConstraintError(f"Failed to add constraint: {e}") from e + + def _get_entity_for_ref(self, ref) -> Any: + """Get the Fusion entity for a reference (string ID or PointRef).""" + if isinstance(ref, PointRef): + element_id = ref.element_id + else: + element_id = str(ref) + + if element_id not in self._id_to_entity: + raise ConstraintError(f"Unknown element ID: {element_id}") + + return self._id_to_entity[element_id] + + def _get_sketch_point_for_ref(self, ref: PointRef) -> Any: + """Get a SketchPoint for a PointRef.""" + entity = self._get_entity_for_ref(ref) + primitive_type = self._get_primitive_type_for_entity(entity) + return VertexMap.get_sketch_point(entity, primitive_type, ref.point_type) + + def _get_primitive_type_for_entity(self, entity) -> str: + """Determine the primitive type from a Fusion entity.""" + obj_type = entity.objectType + if "SketchLine" in obj_type: + return "line" + elif "SketchArc" in obj_type: + return "arc" + elif "SketchCircle" in obj_type: + return "circle" + elif "SketchPoint" in obj_type: + return "point" + elif "Spline" in obj_type: + return "spline" + raise ConstraintError(f"Unknown entity type: {obj_type}") + + # Geometric constraint implementations + + def _add_coincident(self, refs) -> bool: + """Add a coincident constraint between two points.""" + if len(refs) != 2: + raise ConstraintError("COINCIDENT requires exactly 2 references") + + constraints = self._sketch.geometricConstraints + + pt1 = self._get_sketch_point_for_ref(refs[0]) + pt2 = self._get_sketch_point_for_ref(refs[1]) + + constraints.addCoincident(pt1, pt2) + return True + + def _add_horizontal(self, refs) -> bool: + """Add a horizontal constraint.""" + constraints = self._sketch.geometricConstraints + + if len(refs) == 1: + # Single line + entity = self._get_entity_for_ref(refs[0]) + constraints.addHorizontal(entity) + elif len(refs) == 2: + # Two points - add horizontal constraint between them + pt1 = self._get_sketch_point_for_ref(refs[0]) + pt2 = self._get_sketch_point_for_ref(refs[1]) + constraints.addHorizontalPoints(pt1, pt2) + else: + raise ConstraintError("HORIZONTAL requires 1 or 2 references") + + return True + + def _add_vertical(self, refs) -> bool: + """Add a vertical constraint.""" + constraints = self._sketch.geometricConstraints + + if len(refs) == 1: + entity = self._get_entity_for_ref(refs[0]) + constraints.addVertical(entity) + elif len(refs) == 2: + pt1 = self._get_sketch_point_for_ref(refs[0]) + pt2 = self._get_sketch_point_for_ref(refs[1]) + constraints.addVerticalPoints(pt1, pt2) + else: + raise ConstraintError("VERTICAL requires 1 or 2 references") + + return True + + def _add_parallel(self, refs) -> bool: + """Add a parallel constraint between two lines.""" + if len(refs) < 2: + raise ConstraintError("PARALLEL requires at least 2 references") + + constraints = self._sketch.geometricConstraints + + # Add pairwise constraints + first = self._get_entity_for_ref(refs[0]) + for i in range(1, len(refs)): + other = self._get_entity_for_ref(refs[i]) + constraints.addParallel(first, other) + + return True + + def _add_perpendicular(self, refs) -> bool: + """Add a perpendicular constraint between two lines.""" + if len(refs) != 2: + raise ConstraintError("PERPENDICULAR requires exactly 2 references") + + constraints = self._sketch.geometricConstraints + line1 = self._get_entity_for_ref(refs[0]) + line2 = self._get_entity_for_ref(refs[1]) + + constraints.addPerpendicular(line1, line2) + return True + + def _add_tangent(self, refs) -> bool: + """Add a tangent constraint between curves.""" + if len(refs) < 2: + raise ConstraintError("TANGENT requires at least 2 references") + + constraints = self._sketch.geometricConstraints + + # Add pairwise tangent constraints + first = self._get_entity_for_ref(refs[0]) + for i in range(1, len(refs)): + other = self._get_entity_for_ref(refs[i]) + constraints.addTangent(first, other) + + return True + + def _add_equal(self, refs) -> bool: + """Add an equal constraint between curves.""" + if len(refs) < 2: + raise ConstraintError("EQUAL requires at least 2 references") + + constraints = self._sketch.geometricConstraints + + first = self._get_entity_for_ref(refs[0]) + for i in range(1, len(refs)): + other = self._get_entity_for_ref(refs[i]) + constraints.addEqual(first, other) + + return True + + def _add_concentric(self, refs) -> bool: + """Add a concentric constraint between circles/arcs.""" + if len(refs) < 2: + raise ConstraintError("CONCENTRIC requires at least 2 references") + + constraints = self._sketch.geometricConstraints + + first = self._get_entity_for_ref(refs[0]) + for i in range(1, len(refs)): + other = self._get_entity_for_ref(refs[i]) + constraints.addConcentric(first, other) + + return True + + def _add_collinear(self, refs) -> bool: + """Add a collinear constraint between lines.""" + if len(refs) < 2: + raise ConstraintError("COLLINEAR requires at least 2 references") + + constraints = self._sketch.geometricConstraints + + first = self._get_entity_for_ref(refs[0]) + for i in range(1, len(refs)): + other = self._get_entity_for_ref(refs[i]) + constraints.addCollinear(first, other) + + return True + + def _add_fixed(self, refs) -> bool: + """Add a fixed/lock constraint. + + In Fusion 360, fixing geometry is done by setting isFixed = True on the entity, + not via geometricConstraints.addFix() which doesn't exist. + """ + for ref in refs: + entity = self._get_entity_for_ref(ref) + + # In Fusion 360, we fix geometry by setting isFixed property + if hasattr(entity, 'isFixed'): + entity.isFixed = True + # Track the fixed entity + if hasattr(entity, 'entityToken'): + self._fixed_entity_tokens.add(entity.entityToken) + else: + raise ConstraintError( + f"Cannot fix entity {entity}: no isFixed property" + ) + + return True + + def _add_symmetric(self, refs) -> bool: + """Add a symmetry constraint. + + Expects 3 references: two entities and a symmetry line. + """ + if len(refs) != 3: + raise ConstraintError("SYMMETRIC requires exactly 3 references (2 entities + line)") + + constraints = self._sketch.geometricConstraints + + # First two refs are the symmetric entities, third is the symmetry line + if isinstance(refs[0], PointRef) and isinstance(refs[1], PointRef): + # Point symmetry + pt1 = self._get_sketch_point_for_ref(refs[0]) + pt2 = self._get_sketch_point_for_ref(refs[1]) + line = self._get_entity_for_ref(refs[2]) + constraints.addSymmetry(pt1, pt2, line) + else: + # Entity symmetry + entity1 = self._get_entity_for_ref(refs[0]) + entity2 = self._get_entity_for_ref(refs[1]) + line = self._get_entity_for_ref(refs[2]) + constraints.addSymmetry(entity1, entity2, line) + + return True + + def _add_midpoint(self, refs) -> bool: + """Add a midpoint constraint. + + Expects 2 references: point and line. + """ + if len(refs) != 2: + raise ConstraintError("MIDPOINT requires exactly 2 references") + + constraints = self._sketch.geometricConstraints + + # Determine which is the point and which is the line + ref0_is_point = isinstance(refs[0], PointRef) + ref1_is_point = isinstance(refs[1], PointRef) + + if ref0_is_point and not ref1_is_point: + point = self._get_sketch_point_for_ref(refs[0]) + line = self._get_entity_for_ref(refs[1]) + elif ref1_is_point and not ref0_is_point: + point = self._get_sketch_point_for_ref(refs[1]) + line = self._get_entity_for_ref(refs[0]) + else: + raise ConstraintError("MIDPOINT requires one point reference and one line reference") + + constraints.addMidPoint(point, line) + return True + + # Dimensional constraint implementations + + def _add_distance(self, refs, value: float) -> bool: + """Add a distance constraint.""" + if value is None: + raise ConstraintError("DISTANCE requires a value") + + dims = self._sketch.sketchDimensions + distance_cm = value * MM_TO_CM + + if len(refs) == 2: + # Distance between two points + pt1 = self._get_sketch_point_for_ref(refs[0]) + pt2 = self._get_sketch_point_for_ref(refs[1]) + + # Need a text position for the dimension + text_pt = self._adsk_core.Point3D.create( + (pt1.geometry.x + pt2.geometry.x) / 2, + (pt1.geometry.y + pt2.geometry.y) / 2 + 0.5, + 0 + ) + + dim = dims.addDistanceDimension(pt1, pt2, + self._adsk_fusion.DimensionOrientations.AlignedDimensionOrientation, + text_pt) + dim.parameter.value = distance_cm + elif len(refs) == 1: + # Distance from origin - use offset dimension + pt = self._get_sketch_point_for_ref(refs[0]) + origin = self._sketch.originPoint + + text_pt = self._adsk_core.Point3D.create( + pt.geometry.x / 2, + pt.geometry.y / 2 + 0.5, + 0 + ) + + dim = dims.addDistanceDimension(origin, pt, + self._adsk_fusion.DimensionOrientations.AlignedDimensionOrientation, + text_pt) + dim.parameter.value = distance_cm + else: + raise ConstraintError("DISTANCE requires 1 or 2 references") + + return True + + def _add_distance_x(self, refs, value: float) -> bool: + """Add a horizontal distance constraint.""" + if value is None: + raise ConstraintError("DISTANCE_X requires a value") + + dims = self._sketch.sketchDimensions + distance_cm = value * MM_TO_CM + + if len(refs) == 2: + pt1 = self._get_sketch_point_for_ref(refs[0]) + pt2 = self._get_sketch_point_for_ref(refs[1]) + + text_pt = self._adsk_core.Point3D.create( + (pt1.geometry.x + pt2.geometry.x) / 2, + max(pt1.geometry.y, pt2.geometry.y) + 0.5, + 0 + ) + + dim = dims.addDistanceDimension(pt1, pt2, + self._adsk_fusion.DimensionOrientations.HorizontalDimensionOrientation, + text_pt) + dim.parameter.value = distance_cm + elif len(refs) == 1: + pt = self._get_sketch_point_for_ref(refs[0]) + origin = self._sketch.originPoint + + text_pt = self._adsk_core.Point3D.create( + pt.geometry.x / 2, + pt.geometry.y + 0.5, + 0 + ) + + dim = dims.addDistanceDimension(origin, pt, + self._adsk_fusion.DimensionOrientations.HorizontalDimensionOrientation, + text_pt) + dim.parameter.value = distance_cm + else: + raise ConstraintError("DISTANCE_X requires 1 or 2 references") + + return True + + def _add_distance_y(self, refs, value: float) -> bool: + """Add a vertical distance constraint.""" + if value is None: + raise ConstraintError("DISTANCE_Y requires a value") + + dims = self._sketch.sketchDimensions + distance_cm = value * MM_TO_CM + + if len(refs) == 2: + pt1 = self._get_sketch_point_for_ref(refs[0]) + pt2 = self._get_sketch_point_for_ref(refs[1]) + + text_pt = self._adsk_core.Point3D.create( + max(pt1.geometry.x, pt2.geometry.x) + 0.5, + (pt1.geometry.y + pt2.geometry.y) / 2, + 0 + ) + + dim = dims.addDistanceDimension(pt1, pt2, + self._adsk_fusion.DimensionOrientations.VerticalDimensionOrientation, + text_pt) + dim.parameter.value = distance_cm + elif len(refs) == 1: + pt = self._get_sketch_point_for_ref(refs[0]) + origin = self._sketch.originPoint + + text_pt = self._adsk_core.Point3D.create( + pt.geometry.x + 0.5, + pt.geometry.y / 2, + 0 + ) + + dim = dims.addDistanceDimension(origin, pt, + self._adsk_fusion.DimensionOrientations.VerticalDimensionOrientation, + text_pt) + dim.parameter.value = distance_cm + else: + raise ConstraintError("DISTANCE_Y requires 1 or 2 references") + + return True + + def _add_length(self, refs, value: float) -> bool: + """Add a length constraint to a line.""" + if value is None or len(refs) != 1: + raise ConstraintError("LENGTH requires exactly 1 reference and a value") + + dims = self._sketch.sketchDimensions + length_cm = value * MM_TO_CM + + entity = self._get_entity_for_ref(refs[0]) + + # Get midpoint for dimension text placement + if hasattr(entity, "startSketchPoint") and hasattr(entity, "endSketchPoint"): + start = entity.startSketchPoint.geometry + end = entity.endSketchPoint.geometry + text_pt = self._adsk_core.Point3D.create( + (start.x + end.x) / 2, + (start.y + end.y) / 2 + 0.5, + 0 + ) + else: + text_pt = self._adsk_core.Point3D.create(0, 0.5, 0) + + dim = dims.addDistanceDimension( + entity.startSketchPoint, + entity.endSketchPoint, + self._adsk_fusion.DimensionOrientations.AlignedDimensionOrientation, + text_pt + ) + dim.parameter.value = length_cm + + return True + + def _add_radius(self, refs, value: float) -> bool: + """Add a radius constraint to a circle or arc.""" + if value is None or len(refs) != 1: + raise ConstraintError("RADIUS requires exactly 1 reference and a value") + + dims = self._sketch.sketchDimensions + radius_cm = value * MM_TO_CM + + entity = self._get_entity_for_ref(refs[0]) + + # Text position near the entity + if hasattr(entity, "centerSketchPoint"): + center = entity.centerSketchPoint.geometry + text_pt = self._adsk_core.Point3D.create(center.x + radius_cm, center.y, 0) + else: + text_pt = self._adsk_core.Point3D.create(0, 0, 0) + + dim = dims.addRadialDimension(entity, text_pt) + dim.parameter.value = radius_cm + + return True + + def _add_diameter(self, refs, value: float) -> bool: + """Add a diameter constraint to a circle or arc.""" + if value is None or len(refs) != 1: + raise ConstraintError("DIAMETER requires exactly 1 reference and a value") + + dims = self._sketch.sketchDimensions + diameter_cm = value * MM_TO_CM + + entity = self._get_entity_for_ref(refs[0]) + + if hasattr(entity, "centerSketchPoint"): + center = entity.centerSketchPoint.geometry + text_pt = self._adsk_core.Point3D.create(center.x + diameter_cm / 2, center.y, 0) + else: + text_pt = self._adsk_core.Point3D.create(0, 0, 0) + + dim = dims.addDiameterDimension(entity, text_pt) + dim.parameter.value = diameter_cm + + return True + + def _add_angle(self, refs, value: float) -> bool: + """Add an angle constraint between two lines. + + Args: + refs: Two line references + value: Angle in degrees + """ + if value is None or len(refs) != 2: + raise ConstraintError("ANGLE requires exactly 2 references and a value") + + dims = self._sketch.sketchDimensions + angle_rad = math.radians(value) + + line1 = self._get_entity_for_ref(refs[0]) + line2 = self._get_entity_for_ref(refs[1]) + + # Find intersection point for text placement + text_pt = self._adsk_core.Point3D.create(0, 0, 0) + + dim = dims.addAngularDimension(line1, line2, text_pt) + dim.parameter.value = angle_rad + + return True + + def get_solver_status(self) -> tuple[SolverStatus, int]: + """Get the current solver status and degrees of freedom. + + Returns: + Tuple of (SolverStatus, degrees_of_freedom) + """ + if not self._sketch: + return SolverStatus.DIRTY, -1 + + try: + # Count user-created curves and check if they're fixed + # In Fusion 360, fixing is done by setting entity.isFixed = True + user_curves = [] + fixed_curves = [] + + for i in range(self._sketch.sketchCurves.sketchLines.count): + line = self._sketch.sketchCurves.sketchLines.item(i) + # Skip reference geometry (origin axes, etc.) + if line.isReference: + continue + # Check if line is fixed via isFixed property, our tracking, or isFullyConstrained + is_fixed = ( + (hasattr(line, 'isFixed') and line.isFixed) or + line.entityToken in self._fixed_entity_tokens or + (hasattr(line, 'isFullyConstrained') and line.isFullyConstrained) + ) + if is_fixed: + fixed_curves.append(line) + else: + user_curves.append(line) + + for i in range(self._sketch.sketchCurves.sketchCircles.count): + circle = self._sketch.sketchCurves.sketchCircles.item(i) + if circle.isReference: + continue + is_fixed = ( + (hasattr(circle, 'isFixed') and circle.isFixed) or + circle.entityToken in self._fixed_entity_tokens or + (hasattr(circle, 'isFullyConstrained') and circle.isFullyConstrained) + ) + if is_fixed: + fixed_curves.append(circle) + else: + user_curves.append(circle) + + for i in range(self._sketch.sketchCurves.sketchArcs.count): + arc = self._sketch.sketchCurves.sketchArcs.item(i) + if arc.isReference: + continue + is_fixed = ( + (hasattr(arc, 'isFixed') and arc.isFixed) or + arc.entityToken in self._fixed_entity_tokens or + (hasattr(arc, 'isFullyConstrained') and arc.isFullyConstrained) + ) + if is_fixed: + fixed_curves.append(arc) + else: + user_curves.append(arc) + + # If we have fixed curves and no unfixed curves, fully constrained + if len(fixed_curves) > 0 and len(user_curves) == 0: + return SolverStatus.FULLY_CONSTRAINED, 0 + + # Also check the sketch-level property as fallback + if hasattr(self._sketch, 'isFullyConstrained') and self._sketch.isFullyConstrained: + return SolverStatus.FULLY_CONSTRAINED, 0 + + # Estimate DOF from unfixed geometry (user_curves already excludes fixed ones) + dof = 0 + for curve in user_curves: + # Each unfixed curve contributes DOF + # Line: 4 DOF (2 points × 2 coords) + # Circle: 3 DOF (center x, y, radius) + # Arc: 5 DOF (center, radius, start/end angles) + if "SketchLine" in curve.objectType: + dof += 4 + elif "SketchCircle" in curve.objectType: + dof += 3 + elif "SketchArc" in curve.objectType: + dof += 5 + else: + dof += 2 # Default + + # If we found some DOF, return under-constrained + if dof > 0: + return SolverStatus.UNDER_CONSTRAINED, dof + + # Check standalone points (not connected to curves) + for i in range(self._sketch.sketchPoints.count): + point = self._sketch.sketchPoints.item(i) + if point == self._sketch.originPoint: + continue + # Skip points connected to curves (their DOF is counted with the curve) + if point.connectedEntities and point.connectedEntities.count > 0: + continue + # Check if point is fixed via isFixed property, our tracking, or isFullyConstrained + is_fixed = ( + (hasattr(point, 'isFixed') and point.isFixed) or + point.entityToken in self._fixed_entity_tokens or + (hasattr(point, 'isFullyConstrained') and point.isFullyConstrained) + ) + if is_fixed: + continue + dof += 2 + + if dof > 0: + return SolverStatus.UNDER_CONSTRAINED, dof + + # If we have fixed curves and no remaining DOF, fully constrained + if len(fixed_curves) > 0: + return SolverStatus.FULLY_CONSTRAINED, 0 + + # Fallback: if we couldn't determine DOF but sketch seems unconstrained + return SolverStatus.UNDER_CONSTRAINED, 1 + + except Exception: + return SolverStatus.DIRTY, -1 + + def capture_image(self, width: int, height: int) -> bytes: + """Capture an image of the current sketch. + + Note: This requires Fusion 360's UI to be active. + + Args: + width: Image width in pixels + height: Image height in pixels + + Returns: + PNG image data as bytes + + Raises: + AdapterError: If image capture fails + """ + if not self._sketch: + raise AdapterError("No active sketch") + + try: + import os + import tempfile + + # Activate the sketch for viewing + self._sketch.isVisible = True + + # Get the viewport + viewport = self._app.activeViewport + + # Create a temp file for the image + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + temp_path = f.name + + try: + # Save image to temp file + viewport.saveAsImageFile(temp_path, width, height) + + # Read the image data + with open(temp_path, "rb") as f: + image_data = f.read() + + return image_data + + finally: + # Clean up temp file + if os.path.exists(temp_path): + os.remove(temp_path) + + except Exception as e: + raise AdapterError(f"Failed to capture image: {e}") from e + + def close_sketch(self) -> None: + """Close the current sketch editing session.""" + if self._sketch: + # Fusion doesn't require explicit close, but we can finish edit mode + try: + self._design.timeline.moveToEnd() + except Exception: + pass + + def get_element_by_id(self, element_id: str) -> Any | None: + """Get a Fusion 360 entity by its canonical ID. + + Args: + element_id: The canonical element ID + + Returns: + The Fusion 360 entity, or None if not found + """ + return self._id_to_entity.get(element_id) + + def supports_feature(self, feature: str) -> bool: + """Check if a feature is supported by this adapter. + + Args: + feature: Feature name to check + + Returns: + True if the feature is supported + """ + supported = { + "spline": True, + "three_point_arc": True, + "image_capture": True, + "solver_status": False, # Limited support + "construction_geometry": True, + "fixed_spline": True, + "fitted_spline": True, + } + return supported.get(feature, False) + + # Helper methods + + def _point2d_to_point3d(self, point) -> Any: + """Convert a canonical Point2D to a Fusion Point3D. + + Handles unit conversion from mm to cm. + """ + from sketch_canonical.types import Point2D + + if isinstance(point, Point2D): + return self._adsk_core.Point3D.create( + point.x * MM_TO_CM, + point.y * MM_TO_CM, + 0 + ) + elif isinstance(point, (list, tuple)): + return self._adsk_core.Point3D.create( + point[0] * MM_TO_CM, + point[1] * MM_TO_CM, + 0 + ) + else: + raise ValueError(f"Cannot convert {type(point)} to Point3D") + + def _point3d_to_point2d(self, point3d) -> 'Point2D': + """Convert a Fusion Point3D to a canonical Point2D. + + Handles unit conversion from cm to mm. + """ + from sketch_canonical.types import Point2D + + return Point2D( + point3d.x * CM_TO_MM, + point3d.y * CM_TO_MM + ) + + # Export helper methods + + def _export_lines(self, doc: SketchDocument) -> None: + """Export all lines from the sketch.""" + lines = self._sketch.sketchCurves.sketchLines + for i in range(lines.count): + line = lines.item(i) + + # Skip reference geometry (origin X/Y axes) + if line.isReference: + continue + + start = self._point3d_to_point2d(line.startSketchPoint.geometry) + end = self._point3d_to_point2d(line.endSketchPoint.geometry) + + canonical_line = Line( + start=start, + end=end, + construction=line.isConstruction + ) + + prim_id = doc.add_primitive(canonical_line) + self._id_to_entity[prim_id] = line + self._entity_to_id[line.entityToken] = prim_id + + def _export_arcs(self, doc: SketchDocument) -> None: + """Export all arcs from the sketch.""" + arcs = self._sketch.sketchCurves.sketchArcs + for i in range(arcs.count): + arc = arcs.item(i) + + # Skip reference geometry + if arc.isReference: + continue + + center = self._point3d_to_point2d(arc.centerSketchPoint.geometry) + start = self._point3d_to_point2d(arc.startSketchPoint.geometry) + end = self._point3d_to_point2d(arc.endSketchPoint.geometry) + + # Determine CCW from the arc geometry + # Fusion arcs have geometry.startAngle and geometry.endAngle + geom = arc.geometry + start_angle = geom.startAngle + end_angle = geom.endAngle + + # If end > start in default (CCW), then ccw=True + # Otherwise ccw=False + ccw = (end_angle > start_angle) + + canonical_arc = Arc( + center=center, + start_point=start, + end_point=end, + ccw=ccw, + construction=arc.isConstruction + ) + + prim_id = doc.add_primitive(canonical_arc) + self._id_to_entity[prim_id] = arc + self._entity_to_id[arc.entityToken] = prim_id + + def _export_circles(self, doc: SketchDocument) -> None: + """Export all circles from the sketch.""" + circles = self._sketch.sketchCurves.sketchCircles + for i in range(circles.count): + circle = circles.item(i) + + # Skip reference geometry + if circle.isReference: + continue + + center = self._point3d_to_point2d(circle.centerSketchPoint.geometry) + radius = circle.radius * CM_TO_MM + + canonical_circle = Circle( + center=center, + radius=radius, + construction=circle.isConstruction + ) + + prim_id = doc.add_primitive(canonical_circle) + self._id_to_entity[prim_id] = circle + self._entity_to_id[circle.entityToken] = prim_id + + def _export_points(self, doc: SketchDocument) -> None: + """Export all sketch points from the sketch. + + Only exports standalone points, not structural points that are + part of other geometry (line endpoints, arc endpoints, etc.) + """ + points = self._sketch.sketchPoints + for i in range(points.count): + point = points.item(i) + + # Skip origin point + if point == self._sketch.originPoint: + continue + + # Skip points that are connected to curves (structural points) + # Only export standalone/explicit points + if point.connectedEntities and point.connectedEntities.count > 0: + continue + + position = self._point3d_to_point2d(point.geometry) + + canonical_point = Point( + position=position, + construction=False # Points don't have construction flag in Fusion + ) + + prim_id = doc.add_primitive(canonical_point) + self._id_to_entity[prim_id] = point + self._entity_to_id[point.entityToken] = prim_id + + def _export_splines(self, doc: SketchDocument) -> None: + """Export all splines from the sketch.""" + # Export fitted splines + fitted_splines = self._sketch.sketchCurves.sketchFittedSplines + for i in range(fitted_splines.count): + spline = fitted_splines.item(i) + self._export_single_spline(doc, spline) + + # Export fixed splines (NURBS) + fixed_splines = self._sketch.sketchCurves.sketchFixedSplines + for i in range(fixed_splines.count): + spline = fixed_splines.item(i) + self._export_single_spline(doc, spline) + + def _export_single_spline(self, doc: SketchDocument, spline) -> None: + """Export a single spline entity.""" + from sketch_canonical.types import Point2D + + # Get the NURBS data from the spline + geom = spline.geometry + # Handle both cases: geometry may be NurbsCurve3D directly or need conversion + if hasattr(geom, 'asNurbsCurve'): + nurbs = geom.asNurbsCurve + else: + nurbs = geom # Already a NurbsCurve3D + + # Use getData() which returns all NURBS data in a predictable format + data = nurbs.getData() + + # Extract data based on actual structure from getData(): + # Index 0: success (bool) + # Index 1: control points (Point3DVector) + # Index 2: degree (int) + # Index 3: knots (tuple) + # Index 4: isPeriodic (bool) + # Index 5: weights (tuple, empty if non-rational) + # Index 6: isRational (bool) + ctrl_pts = data[1] + degree = data[2] + knots = list(data[3]) + periodic = data[4] + + control_points = [] + for pt in ctrl_pts: + control_points.append(Point2D(pt.x * CM_TO_MM, pt.y * CM_TO_MM)) + + # Extract weights if rational (non-empty weights tuple) + weights = None + if len(data) > 5 and data[5]: + weights = list(data[5]) + + canonical_spline = Spline( + control_points=control_points, + degree=degree, + knots=knots, + weights=weights, + periodic=periodic, + construction=spline.isConstruction + ) + + prim_id = doc.add_primitive(canonical_spline) + self._id_to_entity[prim_id] = spline + self._entity_to_id[spline.entityToken] = prim_id + + def _export_geometric_constraints(self, doc: SketchDocument) -> None: + """Export geometric constraints from the sketch.""" + constraints = self._sketch.geometricConstraints + + for i in range(constraints.count): + constraint = constraints.item(i) + canonical = self._convert_geometric_constraint(constraint) + if canonical: + doc.add_constraint(canonical) + + def _convert_geometric_constraint(self, constraint) -> SketchConstraint | None: + """Convert a Fusion geometric constraint to canonical form.""" + obj_type = constraint.objectType + + try: + if "CoincidentConstraint" in obj_type: + return self._convert_coincident(constraint) + elif "HorizontalConstraint" in obj_type: + return self._convert_horizontal(constraint) + elif "VerticalConstraint" in obj_type: + return self._convert_vertical(constraint) + elif "ParallelConstraint" in obj_type: + return self._convert_parallel(constraint) + elif "PerpendicularConstraint" in obj_type: + return self._convert_perpendicular(constraint) + elif "TangentConstraint" in obj_type: + return self._convert_tangent(constraint) + elif "EqualConstraint" in obj_type: + return self._convert_equal(constraint) + elif "ConcentricConstraint" in obj_type: + return self._convert_concentric(constraint) + elif "CollinearConstraint" in obj_type: + return self._convert_collinear(constraint) + elif "FixConstraint" in obj_type: + return self._convert_fixed(constraint) + elif "SymmetryConstraint" in obj_type: + return self._convert_symmetric(constraint) + elif "MidPointConstraint" in obj_type: + return self._convert_midpoint(constraint) + else: + # Unknown constraint type + return None + except Exception: + return None + + def _get_id_for_entity(self, entity) -> str | None: + """Get the canonical ID for a Fusion entity.""" + token = entity.entityToken + return self._entity_to_id.get(token) + + def _convert_coincident(self, constraint) -> SketchConstraint | None: + """Convert a coincident constraint.""" + # Get the two points involved + pt1 = constraint.point + pt2 = constraint.entity # Could be point or curve + + # For point-to-point coincident + if hasattr(pt2, "geometry"): + # Both are points + id1 = self._get_id_for_entity_or_parent(pt1) + id2 = self._get_id_for_entity_or_parent(pt2) + if id1 and id2: + ref1 = self._point_to_ref(pt1, id1) + ref2 = self._point_to_ref(pt2, id2) + return SketchConstraint( + constraint_type=ConstraintType.COINCIDENT, + references=[ref1, ref2] + ) + return None + + def _get_id_for_entity_or_parent(self, entity) -> str | None: + """Get ID for an entity, checking parent curve if it's a sketch point.""" + # First check if this entity has a direct mapping + if hasattr(entity, "entityToken"): + entity_id = self._entity_to_id.get(entity.entityToken) + if entity_id: + return entity_id + + # For sketch points that are part of curves, find the parent + if hasattr(entity, "geometry") and hasattr(entity, "connectedEntities"): + # It's a SketchPoint - find its parent curve + for connected in entity.connectedEntities: + if hasattr(connected, "entityToken"): + return self._entity_to_id.get(connected.entityToken) + + return None + + def _point_to_ref(self, point, element_id: str) -> PointRef: + """Convert a Fusion SketchPoint to a PointRef.""" + # Determine which point type this is on its parent + parent = None + for connected in point.connectedEntities: + if hasattr(connected, "entityToken"): + if self._entity_to_id.get(connected.entityToken) == element_id: + parent = connected + break + + if not parent: + return PointRef(element_id, PointType.CENTER) + + # Determine point type based on which property matches + obj_type = parent.objectType + if "SketchLine" in obj_type: + if hasattr(parent, "startSketchPoint") and parent.startSketchPoint == point: + return PointRef(element_id, PointType.START) + elif hasattr(parent, "endSketchPoint") and parent.endSketchPoint == point: + return PointRef(element_id, PointType.END) + elif "SketchArc" in obj_type: + if hasattr(parent, "startSketchPoint") and parent.startSketchPoint == point: + return PointRef(element_id, PointType.START) + elif hasattr(parent, "endSketchPoint") and parent.endSketchPoint == point: + return PointRef(element_id, PointType.END) + elif hasattr(parent, "centerSketchPoint") and parent.centerSketchPoint == point: + return PointRef(element_id, PointType.CENTER) + elif "SketchCircle" in obj_type: + return PointRef(element_id, PointType.CENTER) + + return PointRef(element_id, PointType.CENTER) + + def _convert_horizontal(self, constraint) -> SketchConstraint | None: + """Convert a horizontal constraint.""" + entity = constraint.line + entity_id = self._get_id_for_entity(entity) + if entity_id: + return SketchConstraint( + constraint_type=ConstraintType.HORIZONTAL, + references=[entity_id] + ) + return None + + def _convert_vertical(self, constraint) -> SketchConstraint | None: + """Convert a vertical constraint.""" + entity = constraint.line + entity_id = self._get_id_for_entity(entity) + if entity_id: + return SketchConstraint( + constraint_type=ConstraintType.VERTICAL, + references=[entity_id] + ) + return None + + def _convert_parallel(self, constraint) -> SketchConstraint | None: + """Convert a parallel constraint.""" + line1 = constraint.lineOne + line2 = constraint.lineTwo + id1 = self._get_id_for_entity(line1) + id2 = self._get_id_for_entity(line2) + if id1 and id2: + return SketchConstraint( + constraint_type=ConstraintType.PARALLEL, + references=[id1, id2] + ) + return None + + def _convert_perpendicular(self, constraint) -> SketchConstraint | None: + """Convert a perpendicular constraint.""" + line1 = constraint.lineOne + line2 = constraint.lineTwo + id1 = self._get_id_for_entity(line1) + id2 = self._get_id_for_entity(line2) + if id1 and id2: + return SketchConstraint( + constraint_type=ConstraintType.PERPENDICULAR, + references=[id1, id2] + ) + return None + + def _convert_tangent(self, constraint) -> SketchConstraint | None: + """Convert a tangent constraint.""" + curve1 = constraint.curveOne + curve2 = constraint.curveTwo + id1 = self._get_id_for_entity(curve1) + id2 = self._get_id_for_entity(curve2) + if id1 and id2: + return SketchConstraint( + constraint_type=ConstraintType.TANGENT, + references=[id1, id2] + ) + return None + + def _convert_equal(self, constraint) -> SketchConstraint | None: + """Convert an equal constraint.""" + curve1 = constraint.curveOne + curve2 = constraint.curveTwo + id1 = self._get_id_for_entity(curve1) + id2 = self._get_id_for_entity(curve2) + if id1 and id2: + return SketchConstraint( + constraint_type=ConstraintType.EQUAL, + references=[id1, id2] + ) + return None + + def _convert_concentric(self, constraint) -> SketchConstraint | None: + """Convert a concentric constraint.""" + entity1 = constraint.entityOne + entity2 = constraint.entityTwo + id1 = self._get_id_for_entity(entity1) + id2 = self._get_id_for_entity(entity2) + if id1 and id2: + return SketchConstraint( + constraint_type=ConstraintType.CONCENTRIC, + references=[id1, id2] + ) + return None + + def _convert_collinear(self, constraint) -> SketchConstraint | None: + """Convert a collinear constraint.""" + line1 = constraint.lineOne + line2 = constraint.lineTwo + id1 = self._get_id_for_entity(line1) + id2 = self._get_id_for_entity(line2) + if id1 and id2: + return SketchConstraint( + constraint_type=ConstraintType.COLLINEAR, + references=[id1, id2] + ) + return None + + def _convert_fixed(self, constraint) -> SketchConstraint | None: + """Convert a fixed constraint.""" + entity = constraint.entity + entity_id = self._get_id_for_entity(entity) + if entity_id: + return SketchConstraint( + constraint_type=ConstraintType.FIXED, + references=[entity_id] + ) + return None + + def _convert_symmetric(self, constraint) -> SketchConstraint | None: + """Convert a symmetry constraint.""" + entity1 = constraint.entityOne + entity2 = constraint.entityTwo + line = constraint.symmetryLine + id1 = self._get_id_for_entity(entity1) + id2 = self._get_id_for_entity(entity2) + line_id = self._get_id_for_entity(line) + if id1 and id2 and line_id: + return SketchConstraint( + constraint_type=ConstraintType.SYMMETRIC, + references=[id1, id2, line_id] + ) + return None + + def _convert_midpoint(self, constraint) -> SketchConstraint | None: + """Convert a midpoint constraint.""" + point = constraint.point + line = constraint.midPointCurve + point_id = self._get_id_for_entity_or_parent(point) + line_id = self._get_id_for_entity(line) + if point_id and line_id: + ref = self._point_to_ref(point, point_id) + return SketchConstraint( + constraint_type=ConstraintType.MIDPOINT, + references=[ref, line_id] + ) + return None + + def _export_dimensional_constraints(self, doc: SketchDocument) -> None: + """Export dimensional constraints from the sketch.""" + dims = self._sketch.sketchDimensions + + for i in range(dims.count): + dim = dims.item(i) + canonical = self._convert_dimensional_constraint(dim) + if canonical: + doc.add_constraint(canonical) + + def _convert_dimensional_constraint(self, dim) -> SketchConstraint | None: + """Convert a Fusion dimensional constraint to canonical form.""" + obj_type = dim.objectType + + try: + # Get the dimension value in mm + value_cm = dim.parameter.value + value_mm = value_cm * CM_TO_MM + + if "SketchLinearDimension" in obj_type: + return self._convert_linear_dimension(dim, value_mm) + elif "SketchRadialDimension" in obj_type: + return self._convert_radial_dimension(dim, value_mm) + elif "SketchDiameterDimension" in obj_type: + return self._convert_diameter_dimension(dim, value_mm) + elif "SketchAngularDimension" in obj_type: + return self._convert_angular_dimension(dim) + else: + return None + except Exception: + return None + + def _convert_linear_dimension(self, dim, value: float) -> SketchConstraint | None: + """Convert a linear dimension constraint.""" + # Determine if it's distance, length, or offset dimension + orientation = dim.orientation + + entity1 = dim.entityOne + entity2 = dim.entityTwo + + # If both entities are points, it's a distance constraint + if entity2 is not None: + id1 = self._get_id_for_entity_or_parent(entity1) + id2 = self._get_id_for_entity_or_parent(entity2) + if id1 and id2: + ref1 = self._point_to_ref(entity1, id1) if hasattr(entity1, "geometry") else id1 + ref2 = self._point_to_ref(entity2, id2) if hasattr(entity2, "geometry") else id2 + + # Check orientation for X/Y constraints + if orientation == self._adsk_fusion.DimensionOrientations.HorizontalDimensionOrientation: + return SketchConstraint( + constraint_type=ConstraintType.DISTANCE_X, + references=[ref1, ref2], + value=value + ) + elif orientation == self._adsk_fusion.DimensionOrientations.VerticalDimensionOrientation: + return SketchConstraint( + constraint_type=ConstraintType.DISTANCE_Y, + references=[ref1, ref2], + value=value + ) + else: + return SketchConstraint( + constraint_type=ConstraintType.DISTANCE, + references=[ref1, ref2], + value=value + ) + else: + # Single entity - could be length + entity_id = self._get_id_for_entity(entity1) + if entity_id: + return SketchConstraint( + constraint_type=ConstraintType.LENGTH, + references=[entity_id], + value=value + ) + + return None + + def _convert_radial_dimension(self, dim, value: float) -> SketchConstraint | None: + """Convert a radial dimension constraint.""" + entity = dim.entity + entity_id = self._get_id_for_entity(entity) + if entity_id: + return SketchConstraint( + constraint_type=ConstraintType.RADIUS, + references=[entity_id], + value=value + ) + return None + + def _convert_diameter_dimension(self, dim, value: float) -> SketchConstraint | None: + """Convert a diameter dimension constraint.""" + entity = dim.entity + entity_id = self._get_id_for_entity(entity) + if entity_id: + return SketchConstraint( + constraint_type=ConstraintType.DIAMETER, + references=[entity_id], + value=value + ) + return None + + def _convert_angular_dimension(self, dim) -> SketchConstraint | None: + """Convert an angular dimension constraint.""" + # Value is in radians, convert to degrees + value_rad = dim.parameter.value + value_deg = math.degrees(value_rad) + + line1 = dim.lineOne + line2 = dim.lineTwo + id1 = self._get_id_for_entity(line1) + id2 = self._get_id_for_entity(line2) + + if id1 and id2: + return SketchConstraint( + constraint_type=ConstraintType.ANGLE, + references=[id1, id2], + value=value_deg + ) + return None diff --git a/sketch_adapter_fusion/test_scripts/README.md b/sketch_adapter_fusion/test_scripts/README.md new file mode 100644 index 0000000..abcb9c5 --- /dev/null +++ b/sketch_adapter_fusion/test_scripts/README.md @@ -0,0 +1,131 @@ +# Fusion 360 Round-Trip Tests + +This directory contains test scripts designed to run inside Autodesk Fusion 360. +These tests verify the round-trip behavior of the Fusion 360 adapter - ensuring that +sketches can be loaded into Fusion and exported back without loss of essential information. + +## Prerequisites + +1. Autodesk Fusion 360 installed +2. The `canonical_sketch` package accessible from Fusion's Python environment + +## Setup + +### Option 1: Add to Python Path (Recommended for Development) + +The test script automatically adds the project root to the Python path. If you're +running from the default location within the canonical_sketch project, no additional +setup is required. + +### Option 2: Install the Package + +```bash +cd /path/to/canonical_sketch +pip install -e . +``` + +Note: Fusion 360 uses its own Python environment, so you may need to install the +package specifically for Fusion's Python. + +## Running the Tests + +1. Open Autodesk Fusion 360 +2. Go to **Utilities** > **Add-Ins** (or press Shift+S) +3. In the Scripts tab, click the green **+** button (Create Script) +4. Navigate to this directory and select `test_roundtrip.py` +5. Click **Run** + +Alternatively, you can add the script to your Scripts folder: +- Windows: `%appdata%\Autodesk\Autodesk Fusion 360\API\Scripts\` +- macOS: `~/Library/Application Support/Autodesk/Autodesk Fusion 360/API/Scripts/` + +## Test Output + +The tests will: +1. Display a start message +2. Create test sketches in a temporary document +3. Run all tests and capture results +4. Show a summary message box +5. Output detailed results to the **Text Commands** palette + +To view the Text Commands palette: **View** > **Show Text Commands** (or Ctrl+Alt+C) + +## Test Categories + +### Basic Geometry Tests +- `test_single_line` - Single line round-trip +- `test_single_circle` - Single circle round-trip +- `test_single_arc` - Single arc round-trip +- `test_single_point` - Single point round-trip + +### Complex Geometry Tests +- `test_rectangle` - Four connected lines +- `test_mixed_geometry` - Line, arc, circle, and point together +- `test_construction_geometry` - Construction flag preservation + +### Constraint Tests +- `test_horizontal_constraint` - Horizontal line constraint +- `test_vertical_constraint` - Vertical line constraint +- `test_radius_constraint` - Circle/arc radius constraint +- `test_diameter_constraint` - Circle/arc diameter constraint +- `test_coincident_constraint` - Point coincidence +- `test_parallel_constraint` - Parallel lines +- `test_perpendicular_constraint` - Perpendicular lines +- `test_equal_constraint` - Equal length lines +- `test_concentric_constraint` - Concentric circles +- `test_length_constraint` - Line length constraint +- `test_angle_constraint` - Angle between lines + +### Spline Tests +- `test_simple_bspline` - Cubic B-spline (degree 3) +- `test_quadratic_bspline` - Quadratic B-spline (degree 2) + +### Integration Tests +- `test_fully_constrained_rectangle` - Rectangle with full constraint set + +## Troubleshooting + +### "Module not found" Errors + +If you see import errors for `sketch_canonical` or `sketch_adapter_fusion`: + +1. Check that the project root path is correct in the test script +2. Verify the package structure is intact +3. Try installing the package to Fusion's Python environment + +### Test Failures + +If tests fail: +1. Check the Text Commands palette for detailed error messages +2. Verify you have a valid Fusion document open +3. Some constraints may behave differently depending on Fusion version + +### Performance + +The tests create and delete sketches rapidly. If Fusion becomes slow: +1. Close and reopen Fusion +2. Run tests in smaller batches by commenting out test methods + +## Adding New Tests + +To add new tests, add methods to the `FusionTestRunner` class that: +1. Start with `test_` +2. Create a `SketchDocument` with primitives and/or constraints +3. Load it using `self._adapter.load_sketch()` +4. Export using `self._adapter.export_sketch()` +5. Use `assert` statements to verify the results + +Example: +```python +def test_my_new_feature(self): + """Test description.""" + sketch = SketchDocument(name="MyTest") + sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(100, 0))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + # More assertions... +``` diff --git a/sketch_adapter_fusion/test_scripts/__init__.py b/sketch_adapter_fusion/test_scripts/__init__.py new file mode 100644 index 0000000..975d090 --- /dev/null +++ b/sketch_adapter_fusion/test_scripts/__init__.py @@ -0,0 +1,6 @@ +""" +Fusion 360 test scripts for the canonical sketch adapter. + +These scripts are designed to run inside Fusion 360 as add-in scripts. +See README.md in this directory for usage instructions. +""" diff --git a/sketch_adapter_fusion/test_scripts/test_roundtrip.py b/sketch_adapter_fusion/test_scripts/test_roundtrip.py new file mode 100644 index 0000000..bcbc699 --- /dev/null +++ b/sketch_adapter_fusion/test_scripts/test_roundtrip.py @@ -0,0 +1,2047 @@ +""" +Round-trip tests for Fusion 360 adapter. + +This script is designed to be run from inside Fusion 360 as an add-in script. +It tests that sketches can be loaded into Fusion 360 and exported back +without loss of essential information. + +Usage: + 1. Open Fusion 360 + 2. Go to Utilities > Add-Ins > Scripts + 3. Click the green '+' to add a new script + 4. Navigate to this file and run it + +The script will create test sketches, verify the round-trip behavior, +and display results in a message box and text command palette. +""" + +import math +import sys +import traceback +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +# Add the project root to path for imports +# Adjust this path based on where you've installed the canonical_sketch package +SCRIPT_DIR = Path(__file__).parent +PROJECT_ROOT = SCRIPT_DIR.parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +import adsk.core +import adsk.fusion + +from sketch_adapter_fusion import FusionAdapter + +# Import canonical sketch modules +from sketch_canonical import ( + Arc, + Circle, + Line, + Point, + Point2D, + PointRef, + PointType, + SketchDocument, + Spline, +) +from sketch_canonical.constraints import ( + Angle, + Coincident, + Collinear, + Concentric, + Diameter, + Distance, + DistanceX, + DistanceY, + Equal, + Fixed, + Horizontal, + Length, + MidpointConstraint, + Parallel, + Perpendicular, + Radius, + Symmetric, + Tangent, + Vertical, +) +from sketch_canonical.document import SolverStatus + + +class TestStatus(Enum): + """Test result status.""" + PASSED = "PASSED" + FAILED = "FAILED" + SKIPPED = "SKIPPED" + ERROR = "ERROR" + + +@dataclass +class TestResult: + """Result of a single test.""" + name: str + status: TestStatus + message: str = "" + duration: float = 0.0 + + +class FusionTestRunner: + """Test runner for Fusion 360 round-trip tests.""" + + def __init__(self): + self.app = adsk.core.Application.get() + self.ui = self.app.userInterface + self.results: list[TestResult] = [] + self._test_doc = None + self._adapter = None + + def setup(self): + """Set up test environment - create a new document.""" + # Create a new document for testing + doc_type = adsk.core.DocumentTypes.FusionDesignDocumentType + self._test_doc = self.app.documents.add(doc_type) + self._adapter = FusionAdapter() + + def teardown(self): + """Clean up test environment.""" + if self._test_doc: + try: + self._test_doc.close(False) # Close without saving + except: + pass + self._test_doc = None + self._adapter = None + + def run_test(self, name: str, test_func: Callable) -> TestResult: + """Run a single test and capture the result.""" + import time + start_time = time.time() + + try: + # Create fresh adapter for each test + self._adapter = FusionAdapter() + test_func() + duration = time.time() - start_time + return TestResult(name, TestStatus.PASSED, duration=duration) + except AssertionError as e: + duration = time.time() - start_time + return TestResult(name, TestStatus.FAILED, str(e), duration) + except Exception as e: + duration = time.time() - start_time + tb = traceback.format_exc() + return TestResult(name, TestStatus.ERROR, f"{e}\n{tb}", duration) + + def run_all_tests(self) -> list[TestResult]: + """Run all registered tests.""" + self.results = [] + + # Get all test methods + test_methods = [ + (name, getattr(self, name)) + for name in dir(self) + if name.startswith("test_") and callable(getattr(self, name)) + ] + + self.setup() + try: + for name, method in test_methods: + result = self.run_test(name, method) + self.results.append(result) + self._log(f" {result.status.value}: {name}") + finally: + self.teardown() + + return self.results + + def _log(self, message: str): + """Log a message to the text commands palette.""" + palette = self.ui.palettes.itemById("TextCommands") + if palette: + palette.writeText(message) + + def report_results(self): + """Display test results.""" + passed = sum(1 for r in self.results if r.status == TestStatus.PASSED) + failed = sum(1 for r in self.results if r.status == TestStatus.FAILED) + errors = sum(1 for r in self.results if r.status == TestStatus.ERROR) + total = len(self.results) + + summary = f"Test Results: {passed}/{total} passed" + if failed: + summary += f", {failed} failed" + if errors: + summary += f", {errors} errors" + + # Build detailed report + details = [summary, "=" * 50] + + for r in self.results: + status_icon = { + TestStatus.PASSED: "[OK]", + TestStatus.FAILED: "[FAIL]", + TestStatus.ERROR: "[ERR]", + TestStatus.SKIPPED: "[SKIP]", + }[r.status] + + details.append(f"{status_icon} {r.name} ({r.duration:.2f}s)") + if r.message: + # Indent message lines + for line in r.message.split("\n")[:5]: # Limit to first 5 lines + details.append(f" {line}") + + details.append("=" * 50) + full_report = "\n".join(details) + + # Log to text commands + self._log(full_report) + + # Show summary in message box + if failed or errors: + self.ui.messageBox( + f"{summary}\n\nSee Text Commands palette for details.", + "Test Results - Some Tests Failed" + ) + else: + self.ui.messageBox( + f"{summary}\n\nAll tests passed!", + "Test Results - Success" + ) + + # ========================================================================= + # Basic Geometry Tests + # ========================================================================= + + def test_single_line(self): + """Test round-trip of a single line.""" + # Create source sketch + sketch = SketchDocument(name="LineTest") + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 50) + )) + + # Load into Fusion + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + + # Export back + exported = self._adapter.export_sketch() + + # Verify + assert len(exported.primitives) == 1, f"Expected 1 primitive, got {len(exported.primitives)}" + + line = list(exported.primitives.values())[0] + assert isinstance(line, Line), f"Expected Line, got {type(line)}" + assert abs(line.start.x - 0) < 0.01, f"Start X mismatch: {line.start.x}" + assert abs(line.start.y - 0) < 0.01, f"Start Y mismatch: {line.start.y}" + assert abs(line.end.x - 100) < 0.01, f"End X mismatch: {line.end.x}" + assert abs(line.end.y - 50) < 0.01, f"End Y mismatch: {line.end.y}" + + def test_single_circle(self): + """Test round-trip of a single circle.""" + sketch = SketchDocument(name="CircleTest") + sketch.add_primitive(Circle( + center=Point2D(50, 50), + radius=25 + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + circle = list(exported.primitives.values())[0] + assert isinstance(circle, Circle) + assert abs(circle.center.x - 50) < 0.01 + assert abs(circle.center.y - 50) < 0.01 + assert abs(circle.radius - 25) < 0.01 + + def test_single_arc(self): + """Test round-trip of a single arc.""" + sketch = SketchDocument(name="ArcTest") + sketch.add_primitive(Arc( + center=Point2D(0, 0), + start_point=Point2D(50, 0), + end_point=Point2D(0, 50), + ccw=True + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + arc = list(exported.primitives.values())[0] + assert isinstance(arc, Arc) + + # Verify center is preserved + assert abs(arc.center.x - 0) < 0.01 + assert abs(arc.center.y - 0) < 0.01 + + # Verify radius (both start and end should be at radius 50) + start_radius = math.sqrt(arc.start_point.x**2 + arc.start_point.y**2) + end_radius = math.sqrt(arc.end_point.x**2 + arc.end_point.y**2) + assert abs(start_radius - 50) < 0.1, f"Start radius: {start_radius}" + assert abs(end_radius - 50) < 0.1, f"End radius: {end_radius}" + + def test_single_point(self): + """Test round-trip of a single point.""" + sketch = SketchDocument(name="PointTest") + sketch.add_primitive(Point(position=Point2D(25, 75))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + point = list(exported.primitives.values())[0] + assert isinstance(point, Point) + assert abs(point.position.x - 25) < 0.01 + assert abs(point.position.y - 75) < 0.01 + + # ========================================================================= + # Complex Geometry Tests + # ========================================================================= + + def test_rectangle(self): + """Test round-trip of a rectangle (4 lines).""" + sketch = SketchDocument(name="RectangleTest") + + sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(100, 0))) + sketch.add_primitive(Line(start=Point2D(100, 0), end=Point2D(100, 50))) + sketch.add_primitive(Line(start=Point2D(100, 50), end=Point2D(0, 50))) + sketch.add_primitive(Line(start=Point2D(0, 50), end=Point2D(0, 0))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 4 + assert all(isinstance(p, Line) for p in exported.primitives.values()) + + def test_mixed_geometry(self): + """Test round-trip of mixed geometry types.""" + sketch = SketchDocument(name="MixedTest") + + sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(50, 0))) + sketch.add_primitive(Arc( + center=Point2D(50, 25), + start_point=Point2D(50, 0), + end_point=Point2D(75, 25), + ccw=True + )) + sketch.add_primitive(Circle(center=Point2D(100, 50), radius=20)) + sketch.add_primitive(Point(position=Point2D(0, 50))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 4 + + types = {type(p).__name__ for p in exported.primitives.values()} + assert "Line" in types + assert "Arc" in types + assert "Circle" in types + assert "Point" in types + + def test_construction_geometry(self): + """Test that construction flag is preserved.""" + sketch = SketchDocument(name="ConstructionTest") + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 100), + construction=True + )) + sketch.add_primitive(Circle( + center=Point2D(50, 50), + radius=30, + construction=False + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + # Find line and circle + line = next(p for p in exported.primitives.values() if isinstance(p, Line)) + circle = next(p for p in exported.primitives.values() if isinstance(p, Circle)) + + assert line.construction is True, "Line should be construction" + assert circle.construction is False, "Circle should not be construction" + + # ========================================================================= + # Constraint Tests + # ========================================================================= + + def test_horizontal_constraint(self): + """Test horizontal constraint is applied.""" + sketch = SketchDocument(name="HorizontalTest") + line_id = sketch.add_primitive(Line( + start=Point2D(0, 10), # Not horizontal initially + end=Point2D(100, 20) + )) + sketch.add_constraint(Horizontal(line_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + is_horizontal = abs(line.start.y - line.end.y) < 0.01 + assert is_horizontal, f"Line not horizontal: start.y={line.start.y}, end.y={line.end.y}" + + def test_vertical_constraint(self): + """Test vertical constraint is applied.""" + sketch = SketchDocument(name="VerticalTest") + line_id = sketch.add_primitive(Line( + start=Point2D(10, 0), # Not vertical initially + end=Point2D(20, 100) + )) + sketch.add_constraint(Vertical(line_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + is_vertical = abs(line.start.x - line.end.x) < 0.01 + assert is_vertical, f"Line not vertical: start.x={line.start.x}, end.x={line.end.x}" + + def test_radius_constraint(self): + """Test radius constraint is applied.""" + sketch = SketchDocument(name="RadiusTest") + circle_id = sketch.add_primitive(Circle( + center=Point2D(50, 50), + radius=30 # Initial radius + )) + sketch.add_constraint(Radius(circle_id, 50)) # Constrain to radius 50 + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circle = list(exported.primitives.values())[0] + assert abs(circle.radius - 50) < 0.01, f"Radius mismatch: {circle.radius}" + + def test_diameter_constraint(self): + """Test diameter constraint is applied.""" + sketch = SketchDocument(name="DiameterTest") + circle_id = sketch.add_primitive(Circle( + center=Point2D(50, 50), + radius=25 # Initial radius + )) + sketch.add_constraint(Diameter(circle_id, 80)) # Constrain to diameter 80 (radius 40) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circle = list(exported.primitives.values())[0] + assert abs(circle.radius - 40) < 0.01, f"Radius mismatch: {circle.radius} (expected 40)" + + def test_coincident_constraint(self): + """Test coincident constraint connects line endpoints.""" + sketch = SketchDocument(name="CoincidentTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(50, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(50, 5), # Slightly off from l1 end + end=Point2D(100, 50) + )) + sketch.add_constraint(Coincident( + PointRef(l1, PointType.END), + PointRef(l2, PointType.START) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + l1_end = prims[0].end + l2_start = prims[1].start + + distance = math.sqrt((l1_end.x - l2_start.x)**2 + (l1_end.y - l2_start.y)**2) + assert distance < 0.01, f"Points not coincident: distance={distance}" + + def test_parallel_constraint(self): + """Test parallel constraint between two lines.""" + sketch = SketchDocument(name="ParallelTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(0, 50), + end=Point2D(100, 60) # Slightly not parallel + )) + sketch.add_constraint(Parallel(l1, l2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + line1, line2 = prims[0], prims[1] + + # Calculate direction vectors + dir1 = (line1.end.x - line1.start.x, line1.end.y - line1.start.y) + dir2 = (line2.end.x - line2.start.x, line2.end.y - line2.start.y) + + # Normalize + len1 = math.sqrt(dir1[0]**2 + dir1[1]**2) + len2 = math.sqrt(dir2[0]**2 + dir2[1]**2) + dir1 = (dir1[0]/len1, dir1[1]/len1) + dir2 = (dir2[0]/len2, dir2[1]/len2) + + # Cross product should be ~0 for parallel lines + cross = abs(dir1[0]*dir2[1] - dir1[1]*dir2[0]) + assert cross < 0.01, f"Lines not parallel: cross product = {cross}" + + def test_perpendicular_constraint(self): + """Test perpendicular constraint between two lines.""" + sketch = SketchDocument(name="PerpendicularTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(50, 0), + end=Point2D(60, 100) # Slightly not perpendicular + )) + sketch.add_constraint(Perpendicular(l1, l2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + line1, line2 = prims[0], prims[1] + + # Calculate direction vectors + dir1 = (line1.end.x - line1.start.x, line1.end.y - line1.start.y) + dir2 = (line2.end.x - line2.start.x, line2.end.y - line2.start.y) + + # Dot product should be ~0 for perpendicular lines + dot = abs(dir1[0]*dir2[0] + dir1[1]*dir2[1]) + # Normalize by lengths + len1 = math.sqrt(dir1[0]**2 + dir1[1]**2) + len2 = math.sqrt(dir2[0]**2 + dir2[1]**2) + dot_normalized = dot / (len1 * len2) if len1 * len2 > 0 else 0 + + assert dot_normalized < 0.01, f"Lines not perpendicular: dot product = {dot_normalized}" + + def test_equal_constraint(self): + """Test equal constraint between two lines.""" + sketch = SketchDocument(name="EqualTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(0, 50), + end=Point2D(80, 50) # Different length initially + )) + sketch.add_constraint(Equal(l1, l2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + line1, line2 = prims[0], prims[1] + + len1 = math.sqrt((line1.end.x - line1.start.x)**2 + (line1.end.y - line1.start.y)**2) + len2 = math.sqrt((line2.end.x - line2.start.x)**2 + (line2.end.y - line2.start.y)**2) + + assert abs(len1 - len2) < 0.1, f"Lines not equal length: {len1} vs {len2}" + + def test_concentric_constraint(self): + """Test concentric constraint between two circles.""" + sketch = SketchDocument(name="ConcentricTest") + c1 = sketch.add_primitive(Circle( + center=Point2D(50, 50), + radius=30 + )) + c2 = sketch.add_primitive(Circle( + center=Point2D(55, 55), # Slightly off center + radius=50 + )) + sketch.add_constraint(Concentric(c1, c2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + circle1, circle2 = prims[0], prims[1] + + center_distance = math.sqrt( + (circle1.center.x - circle2.center.x)**2 + + (circle1.center.y - circle2.center.y)**2 + ) + assert center_distance < 0.01, f"Circles not concentric: distance = {center_distance}" + + def test_length_constraint(self): + """Test length constraint on a line.""" + sketch = SketchDocument(name="LengthTest") + line_id = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(80, 0) # Initial length 80 + )) + sketch.add_constraint(Length(line_id, 100)) # Constrain to length 100 + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + length = math.sqrt((line.end.x - line.start.x)**2 + (line.end.y - line.start.y)**2) + assert abs(length - 100) < 0.1, f"Length mismatch: {length}" + + def test_angle_constraint(self): + """Test angle constraint between two lines.""" + sketch = SketchDocument(name="AngleTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(70, 50) # Some angle + )) + sketch.add_constraint(Angle(l1, l2, 45)) # Constrain to 45 degrees + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + line1, line2 = prims[0], prims[1] + + # Calculate angle between lines + dir1 = (line1.end.x - line1.start.x, line1.end.y - line1.start.y) + dir2 = (line2.end.x - line2.start.x, line2.end.y - line2.start.y) + + len1 = math.sqrt(dir1[0]**2 + dir1[1]**2) + len2 = math.sqrt(dir2[0]**2 + dir2[1]**2) + + if len1 > 0 and len2 > 0: + dot = dir1[0]*dir2[0] + dir1[1]*dir2[1] + cos_angle = dot / (len1 * len2) + cos_angle = max(-1, min(1, cos_angle)) # Clamp for numerical stability + angle_deg = math.degrees(math.acos(abs(cos_angle))) + assert abs(angle_deg - 45) < 1, f"Angle mismatch: {angle_deg}" + + # ========================================================================= + # Spline Tests + # ========================================================================= + + def test_simple_bspline(self): + """Test round-trip of a simple B-spline.""" + sketch = SketchDocument(name="SplineTest") + + # Create a degree-3 B-spline with 4 control points + spline = Spline.create_uniform_bspline( + control_points=[ + Point2D(0, 0), + Point2D(30, 50), + Point2D(70, 50), + Point2D(100, 0) + ], + degree=3 + ) + sketch.add_primitive(spline) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + exported_spline = list(exported.primitives.values())[0] + assert isinstance(exported_spline, Spline), f"Expected Spline, got {type(exported_spline)}" + assert exported_spline.degree == 3 + assert len(exported_spline.control_points) == 4 + + def test_quadratic_bspline(self): + """Test round-trip of a degree-2 B-spline.""" + sketch = SketchDocument(name="QuadSplineTest") + + spline = Spline.create_uniform_bspline( + control_points=[ + Point2D(0, 0), + Point2D(50, 100), + Point2D(100, 0) + ], + degree=2 + ) + sketch.add_primitive(spline) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + exported_spline = list(exported.primitives.values())[0] + assert isinstance(exported_spline, Spline) + assert exported_spline.degree == 2 + assert len(exported_spline.control_points) == 3 + + # ========================================================================= + # Multiple Constraints Tests + # ========================================================================= + + def test_fully_constrained_rectangle(self): + """Test a fully constrained rectangle with multiple constraints.""" + sketch = SketchDocument(name="FullRectTest") + + # Create rectangle + l1 = sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(100, 0))) + l2 = sketch.add_primitive(Line(start=Point2D(100, 0), end=Point2D(100, 50))) + l3 = sketch.add_primitive(Line(start=Point2D(100, 50), end=Point2D(0, 50))) + l4 = sketch.add_primitive(Line(start=Point2D(0, 50), end=Point2D(0, 0))) + + # Add constraints to make it a proper rectangle + sketch.add_constraint(Horizontal(l1)) + sketch.add_constraint(Horizontal(l3)) + sketch.add_constraint(Vertical(l2)) + sketch.add_constraint(Vertical(l4)) + + # Connect corners + sketch.add_constraint(Coincident(PointRef(l1, PointType.END), PointRef(l2, PointType.START))) + sketch.add_constraint(Coincident(PointRef(l2, PointType.END), PointRef(l3, PointType.START))) + sketch.add_constraint(Coincident(PointRef(l3, PointType.END), PointRef(l4, PointType.START))) + sketch.add_constraint(Coincident(PointRef(l4, PointType.END), PointRef(l1, PointType.START))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 4 + + # Verify it's a proper rectangle + lines = list(exported.primitives.values()) + + # Check horizontal lines are horizontal + horizontal_lines = [l for l in lines if abs(l.start.y - l.end.y) < 0.01] + vertical_lines = [l for l in lines if abs(l.start.x - l.end.x) < 0.01] + + assert len(horizontal_lines) == 2, "Should have 2 horizontal lines" + assert len(vertical_lines) == 2, "Should have 2 vertical lines" + + # ========================================================================= + # Geometry Edge Case Tests + # ========================================================================= + + def test_arc_clockwise(self): + """Test round-trip of a clockwise arc.""" + sketch = SketchDocument(name="ArcCWTest") + sketch.add_primitive(Arc( + center=Point2D(0, 0), + start_point=Point2D(50, 0), + end_point=Point2D(0, 50), + ccw=False # Clockwise + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + arc = list(exported.primitives.values())[0] + assert isinstance(arc, Arc) + + # For CW arc from (50,0) to (0,50), it should go the long way around + # Verify the arc direction is preserved by checking the sweep + # A CW arc from (50,0) to (0,50) should have sweep > 180 degrees + + def test_arc_large_angle(self): + """Test round-trip of a large arc (> 180 degrees).""" + sketch = SketchDocument(name="LargeArcTest") + # Create an arc that sweeps 270 degrees CCW + sketch.add_primitive(Arc( + center=Point2D(0, 0), + start_point=Point2D(50, 0), + end_point=Point2D(0, -50), # 270 degrees CCW from start + ccw=True + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + arc = list(exported.primitives.values())[0] + assert isinstance(arc, Arc) + # Verify radius is preserved + start_radius = math.sqrt(arc.start_point.x**2 + arc.start_point.y**2) + assert abs(start_radius - 50) < 0.1 + + def test_geometry_at_origin(self): + """Test geometry centered at origin.""" + sketch = SketchDocument(name="OriginTest") + sketch.add_primitive(Circle(center=Point2D(0, 0), radius=25)) + sketch.add_primitive(Line(start=Point2D(-50, 0), end=Point2D(50, 0))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 2 + circle = next(p for p in exported.primitives.values() if isinstance(p, Circle)) + assert abs(circle.center.x) < 0.01 + assert abs(circle.center.y) < 0.01 + + def test_small_geometry(self): + """Test very small geometry (precision test).""" + sketch = SketchDocument(name="SmallTest") + # Very small geometry - 0.1mm + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(0.1, 0.05) + )) + sketch.add_primitive(Circle(center=Point2D(1, 1), radius=0.05)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 2 + line = next(p for p in exported.primitives.values() if isinstance(p, Line)) + circle = next(p for p in exported.primitives.values() if isinstance(p, Circle)) + + assert abs(line.end.x - 0.1) < 0.001, f"Small line end X: {line.end.x}" + assert abs(circle.radius - 0.05) < 0.001, f"Small circle radius: {circle.radius}" + + def test_large_geometry(self): + """Test large geometry (1000mm scale).""" + sketch = SketchDocument(name="LargeTest") + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(1000, 500) + )) + sketch.add_primitive(Circle(center=Point2D(500, 500), radius=250)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 2 + line = next(p for p in exported.primitives.values() if isinstance(p, Line)) + circle = next(p for p in exported.primitives.values() if isinstance(p, Circle)) + + assert abs(line.end.x - 1000) < 0.1, f"Large line end X: {line.end.x}" + assert abs(circle.radius - 250) < 0.1, f"Large circle radius: {circle.radius}" + + def test_negative_coordinates(self): + """Test geometry in negative coordinate space.""" + sketch = SketchDocument(name="NegativeTest") + sketch.add_primitive(Line( + start=Point2D(-100, -50), + end=Point2D(-20, -80) + )) + sketch.add_primitive(Circle(center=Point2D(-50, -50), radius=30)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 2 + line = next(p for p in exported.primitives.values() if isinstance(p, Line)) + circle = next(p for p in exported.primitives.values() if isinstance(p, Circle)) + + assert abs(line.start.x - (-100)) < 0.01 + assert abs(line.start.y - (-50)) < 0.01 + assert abs(circle.center.x - (-50)) < 0.01 + assert abs(circle.center.y - (-50)) < 0.01 + + def test_diagonal_line(self): + """Test line at 45-degree angle.""" + sketch = SketchDocument(name="DiagonalTest") + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 100) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + # Verify 45-degree angle + dx = line.end.x - line.start.x + dy = line.end.y - line.start.y + assert abs(dx - dy) < 0.01, "Line should be at 45 degrees" + + # ========================================================================= + # Additional Constraint Tests + # ========================================================================= + + def test_tangent_line_circle(self): + """Test tangent constraint between line and circle.""" + sketch = SketchDocument(name="TangentTest") + circle_id = sketch.add_primitive(Circle( + center=Point2D(50, 50), + radius=30 + )) + line_id = sketch.add_primitive(Line( + start=Point2D(0, 85), + end=Point2D(100, 80) # Nearly tangent + )) + sketch.add_constraint(Tangent(line_id, circle_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + # Verify tangency: distance from center to line should equal radius + circle = next(p for p in exported.primitives.values() if isinstance(p, Circle)) + line = next(p for p in exported.primitives.values() if isinstance(p, Line)) + + # Calculate distance from circle center to line + # Line from (x1,y1) to (x2,y2), point (px,py) + x1, y1 = line.start.x, line.start.y + x2, y2 = line.end.x, line.end.y + px, py = circle.center.x, circle.center.y + + line_len = math.sqrt((x2-x1)**2 + (y2-y1)**2) + if line_len > 0: + dist = abs((y2-y1)*px - (x2-x1)*py + x2*y1 - y2*x1) / line_len + assert abs(dist - circle.radius) < 0.5, f"Not tangent: distance={dist}, radius={circle.radius}" + + def test_tangent_arc_line(self): + """Test tangent constraint between arc and line.""" + sketch = SketchDocument(name="TangentArcTest") + arc_id = sketch.add_primitive(Arc( + center=Point2D(50, 50), + start_point=Point2D(80, 50), + end_point=Point2D(50, 80), + ccw=True + )) + line_id = sketch.add_primitive(Line( + start=Point2D(80, 50), + end=Point2D(120, 55) # Starts at arc endpoint + )) + sketch.add_constraint(Tangent(arc_id, line_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 2 + + def test_collinear_constraint(self): + """Test collinear constraint between two lines.""" + sketch = SketchDocument(name="CollinearTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(50, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(60, 5), # Slightly off the line + end=Point2D(100, 5) + )) + sketch.add_constraint(Collinear(l1, l2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + line1, line2 = prims[0], prims[1] + + # Both lines should have the same Y coordinate (collinear on X-axis) + assert abs(line1.start.y - line2.start.y) < 0.01, "Lines not collinear" + assert abs(line1.end.y - line2.end.y) < 0.01, "Lines not collinear" + + def test_fixed_constraint(self): + """Test fixed constraint locks geometry in place.""" + sketch = SketchDocument(name="FixedTest") + line_id = sketch.add_primitive(Line( + start=Point2D(10, 20), + end=Point2D(50, 60) + )) + sketch.add_constraint(Fixed(line_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + # Fixed geometry should maintain exact position + assert abs(line.start.x - 10) < 0.01 + assert abs(line.start.y - 20) < 0.01 + assert abs(line.end.x - 50) < 0.01 + assert abs(line.end.y - 60) < 0.01 + + def test_distance_constraint(self): + """Test distance constraint between two points.""" + sketch = SketchDocument(name="DistanceTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(50, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(60, 0), # 10mm gap + end=Point2D(100, 0) + )) + # Constrain distance between end of l1 and start of l2 to 20mm + sketch.add_constraint(Distance( + PointRef(l1, PointType.END), + PointRef(l2, PointType.START), + 20 + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + l1_end = prims[0].end + l2_start = prims[1].start + + dist = math.sqrt((l2_start.x - l1_end.x)**2 + (l2_start.y - l1_end.y)**2) + assert abs(dist - 20) < 0.1, f"Distance mismatch: {dist}" + + def test_distance_x_constraint(self): + """Test horizontal distance constraint.""" + sketch = SketchDocument(name="DistanceXTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(30, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(50, 20), + end=Point2D(80, 20) + )) + # Constrain horizontal distance between l1 end and l2 start to 40mm + sketch.add_constraint(DistanceX( + PointRef(l1, PointType.END), + 40, + PointRef(l2, PointType.START) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + l1_end = prims[0].end + l2_start = prims[1].start + + dx = abs(l2_start.x - l1_end.x) + assert abs(dx - 40) < 0.1, f"Horizontal distance mismatch: {dx}" + + def test_distance_y_constraint(self): + """Test vertical distance constraint.""" + sketch = SketchDocument(name="DistanceYTest") + l1 = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(50, 0) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(0, 30), + end=Point2D(50, 30) + )) + # Constrain vertical distance between lines to 50mm + sketch.add_constraint(DistanceY( + PointRef(l1, PointType.START), + 50, + PointRef(l2, PointType.START) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + l1_start = prims[0].start + l2_start = prims[1].start + + dy = abs(l2_start.y - l1_start.y) + assert abs(dy - 50) < 0.1, f"Vertical distance mismatch: {dy}" + + def test_symmetric_constraint(self): + """Test symmetric constraint about a centerline.""" + sketch = SketchDocument(name="SymmetricTest") + # Centerline (vertical) + center_id = sketch.add_primitive(Line( + start=Point2D(50, 0), + end=Point2D(50, 100), + construction=True + )) + # Two lines to be symmetric + l1 = sketch.add_primitive(Line( + start=Point2D(20, 20), + end=Point2D(30, 50) + )) + l2 = sketch.add_primitive(Line( + start=Point2D(80, 20), # Should mirror to x=80 + end=Point2D(70, 50) + )) + sketch.add_constraint(Symmetric(l1, l2, center_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + lines = [p for p in exported.primitives.values() if isinstance(p, Line) and not p.construction] + assert len(lines) == 2 + + # Check that the non-construction lines are symmetric about x=50 + for line in lines: + mid_x = (line.start.x + line.end.x) / 2 + # The two lines' midpoints should be equidistant from x=50 + # This is a simplified check + + def test_midpoint_constraint(self): + """Test midpoint constraint places point at line center.""" + sketch = SketchDocument(name="MidpointTest") + line_id = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 0) + )) + point_id = sketch.add_primitive(Point( + position=Point2D(60, 10) # Not at midpoint initially + )) + sketch.add_constraint(MidpointConstraint( + PointRef(point_id, PointType.CENTER), + line_id + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = next(p for p in exported.primitives.values() if isinstance(p, Line)) + point = next(p for p in exported.primitives.values() if isinstance(p, Point)) + + midpoint_x = (line.start.x + line.end.x) / 2 + midpoint_y = (line.start.y + line.end.y) / 2 + + assert abs(point.position.x - midpoint_x) < 0.1, f"Point not at midpoint X: {point.position.x}" + assert abs(point.position.y - midpoint_y) < 0.1, f"Point not at midpoint Y: {point.position.y}" + + # ========================================================================= + # Spline Edge Case Tests + # ========================================================================= + + def test_higher_degree_spline(self): + """Test round-trip of a degree-4 B-spline.""" + sketch = SketchDocument(name="Degree4SplineTest") + + spline = Spline.create_uniform_bspline( + control_points=[ + Point2D(0, 0), + Point2D(25, 50), + Point2D(50, 0), + Point2D(75, 50), + Point2D(100, 0) + ], + degree=4 + ) + sketch.add_primitive(spline) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + exported_spline = list(exported.primitives.values())[0] + assert isinstance(exported_spline, Spline) + assert exported_spline.degree == 4 + assert len(exported_spline.control_points) == 5 + + def test_many_control_points_spline(self): + """Test spline with many control points.""" + sketch = SketchDocument(name="ManyPointsSplineTest") + + # Create spline with 8 control points + control_pts = [ + Point2D(i * 15, 30 * math.sin(i * 0.8)) + for i in range(8) + ] + spline = Spline.create_uniform_bspline( + control_points=control_pts, + degree=3 + ) + sketch.add_primitive(spline) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 1 + exported_spline = list(exported.primitives.values())[0] + assert len(exported_spline.control_points) == 8 + + # ========================================================================= + # Complex Scenario Tests + # ========================================================================= + + def test_closed_profile(self): + """Test a closed profile with connected lines.""" + sketch = SketchDocument(name="ClosedProfileTest") + + # Create a triangle + l1 = sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(100, 0))) + l2 = sketch.add_primitive(Line(start=Point2D(100, 0), end=Point2D(50, 80))) + l3 = sketch.add_primitive(Line(start=Point2D(50, 80), end=Point2D(0, 0))) + + # Connect all corners + sketch.add_constraint(Coincident(PointRef(l1, PointType.END), PointRef(l2, PointType.START))) + sketch.add_constraint(Coincident(PointRef(l2, PointType.END), PointRef(l3, PointType.START))) + sketch.add_constraint(Coincident(PointRef(l3, PointType.END), PointRef(l1, PointType.START))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 3 + + # Verify the profile is closed (each endpoint touches another) + lines = list(exported.primitives.values()) + endpoints = [] + for line in lines: + endpoints.append((line.start.x, line.start.y)) + endpoints.append((line.end.x, line.end.y)) + + # Each point should appear twice (start of one, end of another) + from collections import Counter + rounded = [(round(x, 1), round(y, 1)) for x, y in endpoints] + counts = Counter(rounded) + assert all(c == 2 for c in counts.values()), "Profile not properly closed" + + def test_nested_geometry(self): + """Test circle inside rectangle (common CAD pattern).""" + sketch = SketchDocument(name="NestedTest") + + # Outer rectangle + sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(100, 0))) + sketch.add_primitive(Line(start=Point2D(100, 0), end=Point2D(100, 80))) + sketch.add_primitive(Line(start=Point2D(100, 80), end=Point2D(0, 80))) + sketch.add_primitive(Line(start=Point2D(0, 80), end=Point2D(0, 0))) + + # Inner circle centered in rectangle + sketch.add_primitive(Circle(center=Point2D(50, 40), radius=25)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 5 + lines = [p for p in exported.primitives.values() if isinstance(p, Line)] + circles = [p for p in exported.primitives.values() if isinstance(p, Circle)] + assert len(lines) == 4 + assert len(circles) == 1 + + def test_concentric_circles(self): + """Test multiple concentric circles.""" + sketch = SketchDocument(name="ConcentricCirclesTest") + + c1 = sketch.add_primitive(Circle(center=Point2D(50, 50), radius=10)) + c2 = sketch.add_primitive(Circle(center=Point2D(52, 52), radius=25)) + c3 = sketch.add_primitive(Circle(center=Point2D(48, 48), radius=40)) + + sketch.add_constraint(Concentric(c1, c2)) + sketch.add_constraint(Concentric(c2, c3)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circles = list(exported.primitives.values()) + assert len(circles) == 3 + + # All circles should share the same center + centers = [(c.center.x, c.center.y) for c in circles] + for i in range(1, len(centers)): + dist = math.sqrt((centers[i][0] - centers[0][0])**2 + + (centers[i][1] - centers[0][1])**2) + assert dist < 0.01, f"Circles not concentric: distance = {dist}" + + def test_slot_profile(self): + """Test a slot profile (two semicircles connected by lines).""" + sketch = SketchDocument(name="SlotTest") + + # Two parallel lines + l1 = sketch.add_primitive(Line(start=Point2D(20, 0), end=Point2D(80, 0))) + l2 = sketch.add_primitive(Line(start=Point2D(80, 40), end=Point2D(20, 40))) + + # Two semicircular arcs + arc1 = sketch.add_primitive(Arc( + center=Point2D(80, 20), + start_point=Point2D(80, 0), + end_point=Point2D(80, 40), + ccw=True + )) + arc2 = sketch.add_primitive(Arc( + center=Point2D(20, 20), + start_point=Point2D(20, 40), + end_point=Point2D(20, 0), + ccw=True + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + lines = [p for p in exported.primitives.values() if isinstance(p, Line)] + arcs = [p for p in exported.primitives.values() if isinstance(p, Arc)] + + assert len(lines) == 2, f"Expected 2 lines, got {len(lines)}" + assert len(arcs) == 2, f"Expected 2 arcs, got {len(arcs)}" + + def test_solver_status_underconstrained(self): + """Test that unconstrained sketch reports correct status.""" + sketch = SketchDocument(name="UnderconstrainedTest") + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 50) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + # An unconstrained line should have degrees of freedom > 0 + assert exported.degrees_of_freedom > 0, \ + f"Expected DOF > 0 for unconstrained sketch, got {exported.degrees_of_freedom}" + + def test_solver_status_fullyconstrained(self): + """Test that fully constrained sketch reports zero DOF.""" + sketch = SketchDocument(name="FullyConstrainedTest") + line_id = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 0) + )) + # Fix the line completely + sketch.add_constraint(Fixed(line_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert exported.degrees_of_freedom == 0, \ + f"Expected DOF = 0 for fixed line, got {exported.degrees_of_freedom}" + + # ========================================================================= + # Error Handling Tests + # ========================================================================= + + def test_multiple_points_standalone(self): + """Test that multiple standalone points are exported correctly.""" + sketch = SketchDocument(name="MultiPointTest") + sketch.add_primitive(Point(position=Point2D(10, 20))) + sketch.add_primitive(Point(position=Point2D(50, 60))) + sketch.add_primitive(Point(position=Point2D(90, 30))) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + points = [p for p in exported.primitives.values() if isinstance(p, Point)] + assert len(points) == 3, f"Expected 3 points, got {len(points)}" + + def test_construction_arc(self): + """Test that construction flag works on arcs.""" + sketch = SketchDocument(name="ConstructionArcTest") + sketch.add_primitive(Arc( + center=Point2D(50, 50), + start_point=Point2D(80, 50), + end_point=Point2D(50, 80), + ccw=True, + construction=True + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + arc = list(exported.primitives.values())[0] + assert arc.construction is True, "Arc should be construction geometry" + + def test_equal_circles(self): + """Test equal constraint between two circles (equal radii).""" + sketch = SketchDocument(name="EqualCirclesTest") + c1 = sketch.add_primitive(Circle(center=Point2D(30, 30), radius=20)) + c2 = sketch.add_primitive(Circle(center=Point2D(80, 30), radius=35)) + sketch.add_constraint(Equal(c1, c2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circles = list(exported.primitives.values()) + assert abs(circles[0].radius - circles[1].radius) < 0.01, \ + f"Circles should have equal radii: {circles[0].radius} vs {circles[1].radius}" + + # ========================================================================= + # Constraint Export Verification Tests + # ========================================================================= + + def test_constraint_export_horizontal(self): + """Test that horizontal constraint is exported back correctly.""" + sketch = SketchDocument(name="ConstraintExportHorizTest") + line_id = sketch.add_primitive(Line( + start=Point2D(10, 20), + end=Point2D(80, 25) # Slightly non-horizontal initially + )) + sketch.add_constraint(Horizontal(line_id)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + # Check geometry is horizontal after constraint + line = list(exported.primitives.values())[0] + assert abs(line.start.y - line.end.y) < 0.01, \ + f"Line should be horizontal: start.y={line.start.y}, end.y={line.end.y}" + + def test_constraint_export_perpendicular(self): + """Test that perpendicular constraint produces 90-degree angle.""" + sketch = SketchDocument(name="ConstraintExportPerpTest") + l1 = sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(50, 0))) + l2 = sketch.add_primitive(Line(start=Point2D(25, 0), end=Point2D(30, 40))) + sketch.add_constraint(Perpendicular(l1, l2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + lines = list(exported.primitives.values()) + # Calculate angle between lines using dot product + dx1, dy1 = lines[0].end.x - lines[0].start.x, lines[0].end.y - lines[0].start.y + dx2, dy2 = lines[1].end.x - lines[1].start.x, lines[1].end.y - lines[1].start.y + dot = dx1 * dx2 + dy1 * dy2 + len1 = math.sqrt(dx1**2 + dy1**2) + len2 = math.sqrt(dx2**2 + dy2**2) + cos_angle = dot / (len1 * len2) if len1 > 0 and len2 > 0 else 0 + assert abs(cos_angle) < 0.01, f"Lines should be perpendicular, cos(angle)={cos_angle}" + + def test_constraint_export_length(self): + """Test that length constraint produces correct line length.""" + sketch = SketchDocument(name="ConstraintExportLengthTest") + line_id = sketch.add_primitive(Line( + start=Point2D(10, 10), + end=Point2D(50, 10) + )) + sketch.add_constraint(Length(line_id, 75.0)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + actual_length = math.sqrt( + (line.end.x - line.start.x)**2 + (line.end.y - line.start.y)**2 + ) + assert abs(actual_length - 75.0) < 0.1, \ + f"Line length should be 75, got {actual_length}" + + # ========================================================================= + # Multiple Constraints on Same Element Tests + # ========================================================================= + + def test_multiple_constraints_horizontal_length(self): + """Test horizontal + length constraints on same line.""" + sketch = SketchDocument(name="MultiConstraintHLTest") + line_id = sketch.add_primitive(Line( + start=Point2D(0, 30), + end=Point2D(40, 35) + )) + sketch.add_constraint(Horizontal(line_id)) + sketch.add_constraint(Length(line_id, 60.0)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + # Should be horizontal + assert abs(line.start.y - line.end.y) < 0.01, "Line should be horizontal" + # Should have correct length + actual_length = abs(line.end.x - line.start.x) + assert abs(actual_length - 60.0) < 0.1, f"Line length should be 60, got {actual_length}" + + def test_multiple_constraints_vertical_length(self): + """Test vertical + length constraints on same line.""" + sketch = SketchDocument(name="MultiConstraintVLTest") + line_id = sketch.add_primitive(Line( + start=Point2D(25, 10), + end=Point2D(30, 50) + )) + sketch.add_constraint(Vertical(line_id)) + sketch.add_constraint(Length(line_id, 80.0)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + # Should be vertical + assert abs(line.start.x - line.end.x) < 0.01, "Line should be vertical" + # Should have correct length + actual_length = abs(line.end.y - line.start.y) + assert abs(actual_length - 80.0) < 0.1, f"Line length should be 80, got {actual_length}" + + def test_multiple_constraints_circle(self): + """Test concentric + equal constraints on circles.""" + sketch = SketchDocument(name="MultiConstraintCircleTest") + c1 = sketch.add_primitive(Circle(center=Point2D(50, 50), radius=20)) + c2 = sketch.add_primitive(Circle(center=Point2D(60, 55), radius=35)) + sketch.add_constraint(Concentric(c1, c2)) + sketch.add_constraint(Equal(c1, c2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circles = list(exported.primitives.values()) + # Should be concentric + assert abs(circles[0].center.x - circles[1].center.x) < 0.01, "Circles should share center X" + assert abs(circles[0].center.y - circles[1].center.y) < 0.01, "Circles should share center Y" + # Should be equal + assert abs(circles[0].radius - circles[1].radius) < 0.01, "Circles should have equal radii" + + # ========================================================================= + # Point-on-Curve Coincident Tests + # ========================================================================= + + def test_point_on_line_midpoint(self): + """Test point constrained to midpoint of line.""" + sketch = SketchDocument(name="PointOnLineMidTest") + line_id = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(100, 0) + )) + point_id = sketch.add_primitive(Point(position=Point2D(30, 20))) + sketch.add_constraint(MidpointConstraint( + PointRef(point_id, PointType.CENTER), + line_id + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = None + point = None + for prim in exported.primitives.values(): + if isinstance(prim, Line): + line = prim + elif isinstance(prim, Point): + point = prim + + assert line is not None and point is not None + midpoint_x = (line.start.x + line.end.x) / 2 + midpoint_y = (line.start.y + line.end.y) / 2 + assert abs(point.position.x - midpoint_x) < 0.01, \ + f"Point X should be at midpoint: {point.position.x} vs {midpoint_x}" + assert abs(point.position.y - midpoint_y) < 0.01, \ + f"Point Y should be at midpoint: {point.position.y} vs {midpoint_y}" + + def test_coincident_point_to_line_endpoint(self): + """Test point coincident with line endpoint.""" + sketch = SketchDocument(name="PointToLineEndTest") + line_id = sketch.add_primitive(Line( + start=Point2D(10, 10), + end=Point2D(80, 50) + )) + point_id = sketch.add_primitive(Point(position=Point2D(50, 30))) + sketch.add_constraint(Coincident( + PointRef(point_id, PointType.CENTER), + PointRef(line_id, PointType.END) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = None + point = None + for prim in exported.primitives.values(): + if isinstance(prim, Line): + line = prim + elif isinstance(prim, Point): + point = prim + + assert line is not None and point is not None + assert abs(point.position.x - line.end.x) < 0.01, "Point should be at line end X" + assert abs(point.position.y - line.end.y) < 0.01, "Point should be at line end Y" + + def test_coincident_point_to_circle_center(self): + """Test point coincident with circle center.""" + sketch = SketchDocument(name="PointToCircleCenterTest") + circle_id = sketch.add_primitive(Circle(center=Point2D(60, 40), radius=25)) + point_id = sketch.add_primitive(Point(position=Point2D(30, 20))) + sketch.add_constraint(Coincident( + PointRef(point_id, PointType.CENTER), + PointRef(circle_id, PointType.CENTER) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circle = None + point = None + for prim in exported.primitives.values(): + if isinstance(prim, Circle): + circle = prim + elif isinstance(prim, Point): + point = prim + + assert circle is not None and point is not None + assert abs(point.position.x - circle.center.x) < 0.01, "Point should be at circle center X" + assert abs(point.position.y - circle.center.y) < 0.01, "Point should be at circle center Y" + + # ========================================================================= + # Closed/Periodic Spline Tests + # ========================================================================= + + def test_periodic_spline(self): + """Test closed/periodic spline round-trip. + + Note: Fusion 360's NurbsCurve3D API doesn't directly support periodic + curves. This test creates a closed spline by connecting start to end. + """ + # Create a closed spline by having coincident start/end + # Use a regular non-periodic spline that forms a closed shape + control_points = [ + Point2D(50, 0), + Point2D(100, 25), + Point2D(100, 75), + Point2D(50, 100), + Point2D(0, 75), + Point2D(0, 25), + Point2D(50, 0), # Same as first point to close + ] + # Standard cubic B-spline knots: n + k + 1 = 7 + 4 = 11 knots + knots = [0, 0, 0, 0, 0.33, 0.5, 0.67, 1, 1, 1, 1] + + sketch = SketchDocument(name="PeriodicSplineTest") + sketch.add_primitive(Spline( + control_points=control_points, + degree=3, + knots=knots, + periodic=False # Use non-periodic with closed endpoints + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + spline = list(exported.primitives.values())[0] + assert isinstance(spline, Spline), "Expected Spline primitive" + assert len(spline.control_points) >= 6, "Should have control points" + # Check that start and end are close (forming closed shape) + start = spline.control_points[0] + end = spline.control_points[-1] + dist = math.sqrt((end.x - start.x)**2 + (end.y - start.y)**2) + assert dist < 1.0, f"Spline should be closed, start-end distance={dist}" + + # ========================================================================= + # Arc Angle Precision Tests + # ========================================================================= + + def test_arc_90_degree(self): + """Test 90-degree arc preserves angle precisely.""" + # Quarter circle arc + sketch = SketchDocument(name="Arc90Test") + sketch.add_primitive(Arc( + center=Point2D(50, 50), + start_point=Point2D(80, 50), # Right + end_point=Point2D(50, 80), # Top + ccw=True + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + arc = list(exported.primitives.values())[0] + # Calculate sweep angle + start_angle = math.atan2(arc.start_point.y - arc.center.y, arc.start_point.x - arc.center.x) + end_angle = math.atan2(arc.end_point.y - arc.center.y, arc.end_point.x - arc.center.x) + sweep = end_angle - start_angle + if sweep < 0: + sweep += 2 * math.pi + sweep_deg = math.degrees(sweep) + assert abs(sweep_deg - 90) < 1.0, f"Arc should be 90 degrees, got {sweep_deg}" + + def test_arc_180_degree(self): + """Test 180-degree arc (semicircle) preserves angle precisely.""" + sketch = SketchDocument(name="Arc180Test") + sketch.add_primitive(Arc( + center=Point2D(50, 50), + start_point=Point2D(80, 50), # Right + end_point=Point2D(20, 50), # Left + ccw=True + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + arc = list(exported.primitives.values())[0] + # Calculate sweep angle + start_angle = math.atan2(arc.start_point.y - arc.center.y, arc.start_point.x - arc.center.x) + end_angle = math.atan2(arc.end_point.y - arc.center.y, arc.end_point.x - arc.center.x) + sweep = end_angle - start_angle + if sweep < 0: + sweep += 2 * math.pi + sweep_deg = math.degrees(sweep) + assert abs(sweep_deg - 180) < 1.0, f"Arc should be 180 degrees, got {sweep_deg}" + + def test_arc_45_degree(self): + """Test 45-degree arc preserves angle precisely.""" + # 45 degree arc + r = 30 + sketch = SketchDocument(name="Arc45Test") + sketch.add_primitive(Arc( + center=Point2D(50, 50), + start_point=Point2D(50 + r, 50), # Right (0 degrees) + end_point=Point2D(50 + r * math.cos(math.radians(45)), + 50 + r * math.sin(math.radians(45))), # 45 degrees + ccw=True + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + arc = list(exported.primitives.values())[0] + start_angle = math.atan2(arc.start_point.y - arc.center.y, arc.start_point.x - arc.center.x) + end_angle = math.atan2(arc.end_point.y - arc.center.y, arc.end_point.x - arc.center.x) + sweep = end_angle - start_angle + if sweep < 0: + sweep += 2 * math.pi + sweep_deg = math.degrees(sweep) + assert abs(sweep_deg - 45) < 1.0, f"Arc should be 45 degrees, got {sweep_deg}" + + # ========================================================================= + # Weighted NURBS Spline Tests + # ========================================================================= + + def test_weighted_spline(self): + """Test NURBS spline with non-uniform weights.""" + control_points = [ + Point2D(0, 0), + Point2D(25, 50), + Point2D(75, 50), + Point2D(100, 0), + ] + # Non-uniform weights - middle points have higher weight + weights = [1.0, 2.0, 2.0, 1.0] + knots = [0, 0, 0, 0, 1, 1, 1, 1] + + sketch = SketchDocument(name="WeightedSplineTest") + sketch.add_primitive(Spline( + control_points=control_points, + degree=3, + knots=knots, + weights=weights + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + spline = list(exported.primitives.values())[0] + assert isinstance(spline, Spline), "Expected Spline primitive" + assert len(spline.control_points) == 4, "Should have 4 control points" + + # ========================================================================= + # Empty Sketch Test + # ========================================================================= + + def test_empty_sketch(self): + """Test that empty sketch exports correctly.""" + sketch = SketchDocument(name="EmptySketchTest") + # No primitives added + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + assert len(exported.primitives) == 0, \ + f"Empty sketch should have no primitives, got {len(exported.primitives)}" + + # ========================================================================= + # Constraint Value Precision Tests + # ========================================================================= + + def test_length_precision(self): + """Test dimensional constraint with high precision value.""" + sketch = SketchDocument(name="LengthPrecisionTest") + line_id = sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(50, 0) + )) + # Use a precise value + precise_length = 47.123456 + sketch.add_constraint(Length(line_id, precise_length)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + actual_length = abs(line.end.x - line.start.x) + # Allow small tolerance for floating point + assert abs(actual_length - precise_length) < 0.001, \ + f"Length should be {precise_length}, got {actual_length}" + + def test_radius_precision(self): + """Test radius constraint with high precision value.""" + sketch = SketchDocument(name="RadiusPrecisionTest") + circle_id = sketch.add_primitive(Circle( + center=Point2D(50, 50), + radius=20 + )) + precise_radius = 33.789012 + sketch.add_constraint(Radius(circle_id, precise_radius)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circle = list(exported.primitives.values())[0] + assert abs(circle.radius - precise_radius) < 0.001, \ + f"Radius should be {precise_radius}, got {circle.radius}" + + def test_angle_precision(self): + """Test angle constraint with precise value.""" + sketch = SketchDocument(name="AnglePrecisionTest") + l1 = sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(50, 0))) + l2 = sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(40, 30))) + precise_angle = 37.5 # degrees + sketch.add_constraint(Angle(l1, l2, precise_angle)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + lines = list(exported.primitives.values()) + # Calculate angle between lines + dx1, dy1 = lines[0].end.x - lines[0].start.x, lines[0].end.y - lines[0].start.y + dx2, dy2 = lines[1].end.x - lines[1].start.x, lines[1].end.y - lines[1].start.y + dot = dx1 * dx2 + dy1 * dy2 + cross = dx1 * dy2 - dy1 * dx2 + angle_rad = math.atan2(abs(cross), dot) + angle_deg = math.degrees(angle_rad) + assert abs(angle_deg - precise_angle) < 0.5, \ + f"Angle should be {precise_angle}, got {angle_deg}" + + # ========================================================================= + # 3+ Element Equal Chain Tests + # ========================================================================= + + def test_equal_chain_three_lines(self): + """Test equal constraint across three lines.""" + sketch = SketchDocument(name="EqualChain3LinesTest") + l1 = sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(30, 0))) + l2 = sketch.add_primitive(Line(start=Point2D(0, 20), end=Point2D(50, 20))) + l3 = sketch.add_primitive(Line(start=Point2D(0, 40), end=Point2D(70, 40))) + sketch.add_constraint(Equal(l1, l2, l3)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + lines = list(exported.primitives.values()) + lengths = [ + math.sqrt((l.end.x - l.start.x)**2 + (l.end.y - l.start.y)**2) + for l in lines + ] + # All lines should have equal length + assert abs(lengths[0] - lengths[1]) < 0.1, \ + f"Lines 1 and 2 should be equal: {lengths[0]} vs {lengths[1]}" + assert abs(lengths[1] - lengths[2]) < 0.1, \ + f"Lines 2 and 3 should be equal: {lengths[1]} vs {lengths[2]}" + + def test_equal_chain_four_circles(self): + """Test equal constraint across four circles.""" + sketch = SketchDocument(name="EqualChain4CirclesTest") + c1 = sketch.add_primitive(Circle(center=Point2D(20, 20), radius=10)) + c2 = sketch.add_primitive(Circle(center=Point2D(60, 20), radius=15)) + c3 = sketch.add_primitive(Circle(center=Point2D(20, 60), radius=20)) + c4 = sketch.add_primitive(Circle(center=Point2D(60, 60), radius=25)) + sketch.add_constraint(Equal(c1, c2, c3, c4)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + circles = list(exported.primitives.values()) + radii = [c.radius for c in circles] + # All circles should have equal radius + for i in range(len(radii) - 1): + assert abs(radii[i] - radii[i+1]) < 0.1, \ + f"Circles {i} and {i+1} should have equal radii: {radii[i]} vs {radii[i+1]}" + + # ========================================================================= + # Mixed Profile Tests (Fillet Pattern) + # ========================================================================= + + def test_arc_tangent_to_two_lines(self): + """Test arc tangent to two lines (fillet pattern).""" + sketch = SketchDocument(name="FilletPatternTest") + # Two perpendicular lines + l1 = sketch.add_primitive(Line(start=Point2D(0, 50), end=Point2D(50, 50))) + l2 = sketch.add_primitive(Line(start=Point2D(50, 50), end=Point2D(50, 0))) + # Arc connecting them + arc = sketch.add_primitive(Arc( + center=Point2D(35, 35), + start_point=Point2D(35, 50), + end_point=Point2D(50, 35), + ccw=False + )) + # Make lines perpendicular + sketch.add_constraint(Perpendicular(l1, l2)) + # Make arc tangent to both lines + sketch.add_constraint(Tangent(l1, arc)) + sketch.add_constraint(Tangent(arc, l2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + assert len(prims) == 3, f"Expected 3 primitives, got {len(prims)}" + + def test_smooth_corner_profile(self): + """Test L-shaped profile with rounded corner.""" + sketch = SketchDocument(name="SmoothCornerTest") + # Create L-shape with arc corner + l1 = sketch.add_primitive(Line(start=Point2D(0, 30), end=Point2D(30, 30))) + arc = sketch.add_primitive(Arc( + center=Point2D(30, 20), + start_point=Point2D(30, 30), + end_point=Point2D(40, 20), + ccw=False + )) + l2 = sketch.add_primitive(Line(start=Point2D(40, 20), end=Point2D(40, 0))) + + # Connect endpoints + sketch.add_constraint(Coincident( + PointRef(l1, PointType.END), + PointRef(arc, PointType.START) + )) + sketch.add_constraint(Coincident( + PointRef(arc, PointType.END), + PointRef(l2, PointType.START) + )) + # Make tangent connections + sketch.add_constraint(Tangent(l1, arc)) + sketch.add_constraint(Tangent(arc, l2)) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + prims = list(exported.primitives.values()) + assert len(prims) == 3, f"Expected 3 primitives, got {len(prims)}" + + # ========================================================================= + # Edge Case Tests + # ========================================================================= + + def test_very_small_dimensions(self): + """Test geometry with very small dimensions (micrometer scale).""" + sketch = SketchDocument(name="MicroScaleTest") + # 0.001mm = 1 micrometer scale + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(0.01, 0.01) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + assert abs(line.end.x - 0.01) < 0.001, f"Small dimension not preserved: {line.end.x}" + + def test_very_large_dimensions(self): + """Test geometry with very large dimensions (meter scale in mm).""" + sketch = SketchDocument(name="LargeScaleTest") + # 1000mm = 1 meter + sketch.add_primitive(Line( + start=Point2D(0, 0), + end=Point2D(1000, 1000) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + line = list(exported.primitives.values())[0] + assert abs(line.end.x - 1000) < 0.1, f"Large dimension not preserved: {line.end.x}" + + def test_coincident_chain(self): + """Test chain of coincident constraints forming connected path.""" + sketch = SketchDocument(name="CoincidentChainTest") + l1 = sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(30, 0))) + l2 = sketch.add_primitive(Line(start=Point2D(35, 5), end=Point2D(60, 30))) + l3 = sketch.add_primitive(Line(start=Point2D(65, 35), end=Point2D(30, 60))) + + # Chain the endpoints + sketch.add_constraint(Coincident( + PointRef(l1, PointType.END), + PointRef(l2, PointType.START) + )) + sketch.add_constraint(Coincident( + PointRef(l2, PointType.END), + PointRef(l3, PointType.START) + )) + + self._adapter.create_sketch(sketch.name) + self._adapter.load_sketch(sketch) + exported = self._adapter.export_sketch() + + lines = list(exported.primitives.values()) + # Check chain connectivity + assert abs(lines[0].end.x - lines[1].start.x) < 0.01, "L1 end should connect to L2 start" + assert abs(lines[0].end.y - lines[1].start.y) < 0.01, "L1 end should connect to L2 start" + assert abs(lines[1].end.x - lines[2].start.x) < 0.01, "L2 end should connect to L3 start" + assert abs(lines[1].end.y - lines[2].start.y) < 0.01, "L2 end should connect to L3 start" + + +def run(context): + """Main entry point for Fusion 360 script.""" + ui = None + try: + app = adsk.core.Application.get() + ui = app.userInterface + + # Show the text commands palette for output + palette = ui.palettes.itemById("TextCommands") + if palette: + palette.isVisible = True + + ui.messageBox( + "Starting Fusion 360 Round-Trip Tests.\n\n" + "Results will appear in a message box and the Text Commands palette.", + "Round-Trip Tests" + ) + + runner = FusionTestRunner() + runner.run_all_tests() + runner.report_results() + + except Exception: + if ui: + ui.messageBox(f"Test run failed:\n{traceback.format_exc()}") + + +# Allow running as script +if __name__ == "__main__": + run(None) diff --git a/sketch_adapter_fusion/vertex_map.py b/sketch_adapter_fusion/vertex_map.py new file mode 100644 index 0000000..76846ef --- /dev/null +++ b/sketch_adapter_fusion/vertex_map.py @@ -0,0 +1,160 @@ +"""Vertex mapping utilities for Fusion 360 sketch adapter. + +Unlike FreeCAD which uses numeric vertex indices, Fusion 360 provides direct +property access to sketch points (startSketchPoint, endSketchPoint, centerSketchPoint). +This module provides a consistent interface for accessing these points based on +canonical PointType values. +""" + +from sketch_canonical.types import PointType + + +class VertexMap: + """Maps between canonical PointType and Fusion 360 sketch point properties. + + Fusion 360 sketch entities expose points as properties: + - SketchLine: startSketchPoint, endSketchPoint + - SketchArc: startSketchPoint, endSketchPoint, centerSketchPoint + - SketchCircle: centerSketchPoint + - SketchPoint: geometry (Point3D) + - SketchFittedSpline: startSketchPoint, endSketchPoint, fitPoints (collection) + """ + + # Mapping from (primitive_type, PointType) to Fusion 360 property name + POINT_PROPERTY_MAP = { + # Line points + ("line", PointType.START): "startSketchPoint", + ("line", PointType.END): "endSketchPoint", + + # Arc points + ("arc", PointType.START): "startSketchPoint", + ("arc", PointType.END): "endSketchPoint", + ("arc", PointType.CENTER): "centerSketchPoint", + + # Circle points + ("circle", PointType.CENTER): "centerSketchPoint", + + # Point + ("point", PointType.CENTER): "geometry", + + # Spline points + ("spline", PointType.START): "startSketchPoint", + ("spline", PointType.END): "endSketchPoint", + } + + @classmethod + def get_point_property(cls, primitive_type: str, point_type: PointType) -> str: + """Get the Fusion 360 property name for accessing a specific point. + + Args: + primitive_type: Type of primitive ("line", "arc", "circle", "point", "spline") + point_type: The canonical PointType + + Returns: + Property name to access on the Fusion 360 sketch entity + + Raises: + ValueError: If the combination is not valid + """ + key = (primitive_type.lower(), point_type) + if key not in cls.POINT_PROPERTY_MAP: + raise ValueError( + f"Invalid point type {point_type} for primitive type {primitive_type}" + ) + return cls.POINT_PROPERTY_MAP[key] + + @classmethod + def get_sketch_point(cls, entity, primitive_type: str, point_type: PointType): + """Get a SketchPoint from a Fusion 360 sketch entity. + + Args: + entity: Fusion 360 sketch entity (SketchLine, SketchArc, etc.) + primitive_type: Type of primitive + point_type: The canonical PointType + + Returns: + The SketchPoint object for use in constraints + """ + # Special case: SketchPoint entities should return themselves + # for constraint purposes (not their geometry which is Point3D) + if primitive_type.lower() == "point" and point_type == PointType.CENTER: + return entity # Return the SketchPoint itself + + prop_name = cls.get_point_property(primitive_type, point_type) + return getattr(entity, prop_name) + + @classmethod + def get_point_types_for_primitive(cls, primitive_type: str) -> list: + """Get all valid PointTypes for a given primitive type. + + Args: + primitive_type: Type of primitive + + Returns: + List of valid PointType values + """ + ptype = primitive_type.lower() + return [ + pt for (pt_type, pt), _ in cls.POINT_PROPERTY_MAP.items() + if pt_type == ptype + ] + + @classmethod + def get_point_type_from_property(cls, primitive_type: str, property_name: str) -> PointType: + """Get the canonical PointType from a Fusion 360 property name. + + Args: + primitive_type: Type of primitive + property_name: Fusion 360 property name + + Returns: + The corresponding PointType + + Raises: + ValueError: If the property is not recognized + """ + ptype = primitive_type.lower() + for (pt_type, point_type), prop in cls.POINT_PROPERTY_MAP.items(): + if pt_type == ptype and prop == property_name: + return point_type + raise ValueError( + f"Unknown property {property_name} for primitive type {primitive_type}" + ) + + +def get_point_from_sketch_entity(entity, point_type: PointType): + """Extract a Point3D from a Fusion 360 sketch entity at the specified point type. + + This is a convenience function that handles the different entity types + and returns the actual Point3D geometry. + + Args: + entity: Fusion 360 sketch entity + point_type: The canonical PointType + + Returns: + adsk.core.Point3D object + """ + # Determine primitive type from entity + entity_type = entity.objectType + + if "SketchLine" in entity_type: + primitive_type = "line" + elif "SketchArc" in entity_type: + primitive_type = "arc" + elif "SketchCircle" in entity_type: + primitive_type = "circle" + elif "SketchPoint" in entity_type: + primitive_type = "point" + elif "SketchFittedSpline" in entity_type or "SketchFixedSpline" in entity_type: + primitive_type = "spline" + else: + raise ValueError(f"Unknown entity type: {entity_type}") + + sketch_point = VertexMap.get_sketch_point(entity, primitive_type, point_type) + + # For most entities, sketch_point is a SketchPoint with a geometry property + # For SketchPoint entities with CENTER, it's already a Point3D + if hasattr(sketch_point, "geometry"): + return sketch_point.geometry + return sketch_point diff --git a/sketch_canonical/__init__.py b/sketch_canonical/__init__.py index b46ad3e..9c00f9b 100644 --- a/sketch_canonical/__init__.py +++ b/sketch_canonical/__init__.py @@ -48,95 +48,94 @@ __version__ = "0.1.0" # Core types -from .types import ( - Point2D, - Vector2D, - ElementId, - ElementPrefix, - PointType, - PointRef, -) - -# Geometry primitives -from .primitives import ( - SketchPrimitive, - Line, - Arc, - Circle, - Point, - Spline, +# Adapter interface +from .adapter import ( + AdapterError, + ConstraintError, + ExportError, + GeometryError, + SketchBackendAdapter, + SketchCreationError, ) # Constraints from .constraints import ( - ConstraintType, - ConstraintStatus, - SketchConstraint, CONSTRAINT_RULES, + Angle, # Convenience constructors Coincident, - Tangent, - Perpendicular, - Parallel, - Concentric, - Equal, Collinear, - Horizontal, - Vertical, - Fixed, + Concentric, + ConstraintStatus, + ConstraintType, + Diameter, Distance, DistanceX, DistanceY, + Equal, + Fixed, + Horizontal, Length, + MidpointConstraint, + Parallel, + Perpendicular, Radius, - Diameter, - Angle, + SketchConstraint, Symmetric, - MidpointConstraint, + Tangent, + Vertical, ) # Document from .document import ( - SolverStatus, SketchDocument, + SolverStatus, ) -# Validation -from .validation import ( - ValidationSeverity, - ValidationIssue, - ValidationResult, - validate_sketch, - validate_primitive, - validate_constraint, - DEFAULT_TOLERANCE, +# Geometry primitives +from .primitives import ( + Arc, + Circle, + Line, + Point, + SketchPrimitive, + Spline, ) # Serialization from .serialization import ( SketchEncoder, - sketch_to_json, - sketch_from_json, - sketch_to_dict, - dict_to_sketch, - primitive_to_dict, - dict_to_primitive, constraint_to_dict, dict_to_constraint, - point_ref_to_dict, dict_to_point_ref, - save_sketch, + dict_to_primitive, + dict_to_sketch, load_sketch, + point_ref_to_dict, + primitive_to_dict, + save_sketch, + sketch_from_json, + sketch_to_dict, + sketch_to_json, +) +from .types import ( + ElementId, + ElementPrefix, + Point2D, + PointRef, + PointType, + Vector2D, ) -# Adapter interface -from .adapter import ( - SketchBackendAdapter, - AdapterError, - SketchCreationError, - GeometryError, - ConstraintError, - ExportError, +# Validation +from .validation import ( + DEFAULT_TOLERANCE, + ValidationIssue, + ValidationResult, + ValidationSeverity, + validate_constraint, + validate_primitive, + validate_sketch, ) __all__ = [ diff --git a/sketch_canonical/adapter.py b/sketch_canonical/adapter.py index f352cff..c76a9aa 100644 --- a/sketch_canonical/adapter.py +++ b/sketch_canonical/adapter.py @@ -1,11 +1,11 @@ """Abstract adapter interface for CAD platform backends.""" from abc import ABC, abstractmethod -from typing import Any, Optional, Tuple +from typing import Any +from .constraints import SketchConstraint from .document import SketchDocument, SolverStatus from .primitives import SketchPrimitive -from .constraints import SketchConstraint class SketchBackendAdapter(ABC): @@ -24,7 +24,7 @@ class SketchBackendAdapter(ABC): """ @abstractmethod - def create_sketch(self, name: str, plane: Optional[Any] = None) -> None: + def create_sketch(self, name: str, plane: Any | None = None) -> None: """ Create a new empty sketch. @@ -87,7 +87,7 @@ def add_constraint(self, constraint: SketchConstraint) -> bool: pass @abstractmethod - def get_solver_status(self) -> Tuple[SolverStatus, int]: + def get_solver_status(self) -> tuple[SolverStatus, int]: """ Get the current solver status. @@ -120,7 +120,7 @@ def close_sketch(self) -> None: """ pass - def get_element_by_id(self, element_id: str) -> Optional[Any]: + def get_element_by_id(self, element_id: str) -> Any | None: """ Get the platform-specific entity for a canonical element ID. diff --git a/sketch_canonical/constraints.py b/sketch_canonical/constraints.py index f89bc7f..60f0f56 100644 --- a/sketch_canonical/constraints.py +++ b/sketch_canonical/constraints.py @@ -1,11 +1,10 @@ """Constraint types and data structures for the canonical sketch schema.""" -from dataclasses import dataclass, field -from enum import Enum -from typing import Optional, Union import uuid +from dataclasses import dataclass +from enum import Enum -from .types import PointRef, PointType +from .types import PointRef class ConstraintType(Enum): @@ -191,16 +190,16 @@ class SketchConstraint: """ id: str # Unique constraint ID constraint_type: ConstraintType - references: list[Union[str, PointRef]] # Element IDs or PointRefs - value: Optional[float] = None # For dimensional constraints (mm or degrees) + references: list[str | PointRef] # Element IDs or PointRefs + value: float | None = None # For dimensional constraints (mm or degrees) # Connection hints for curve-to-curve constraints - connection_point: Optional[PointRef] = None # Where tangent/perpendicular occurs + connection_point: PointRef | None = None # Where tangent/perpendicular occurs # Metadata inferred: bool = False # True if AI/algorithm suggested confidence: float = 1.0 # Confidence for inferred constraints - source: Optional[str] = None # Origin: "user", "ai", "detected" + source: str | None = None # Origin: "user", "ai", "detected" # Status (populated after solving) status: ConstraintStatus = ConstraintStatus.UNKNOWN @@ -240,7 +239,7 @@ def Coincident(pt1: PointRef, pt2: PointRef, **kwargs) -> SketchConstraint: ) -def Tangent(elem1: str, elem2: str, at: Optional[PointRef] = None, **kwargs) -> SketchConstraint: +def Tangent(elem1: str, elem2: str, at: PointRef | None = None, **kwargs) -> SketchConstraint: """Create a tangent constraint between two curves.""" return SketchConstraint( id=kwargs.pop('id', _generate_id()), @@ -346,7 +345,7 @@ def Distance(pt1: PointRef, pt2: PointRef, value: float, **kwargs) -> SketchCons ) -def DistanceX(pt: PointRef, value: float, pt2: Optional[PointRef] = None, **kwargs) -> SketchConstraint: +def DistanceX(pt: PointRef, value: float, pt2: PointRef | None = None, **kwargs) -> SketchConstraint: """Create a horizontal distance constraint.""" refs = [pt] if pt2 is None else [pt, pt2] return SketchConstraint( @@ -358,7 +357,7 @@ def DistanceX(pt: PointRef, value: float, pt2: Optional[PointRef] = None, **kwar ) -def DistanceY(pt: PointRef, value: float, pt2: Optional[PointRef] = None, **kwargs) -> SketchConstraint: +def DistanceY(pt: PointRef, value: float, pt2: PointRef | None = None, **kwargs) -> SketchConstraint: """Create a vertical distance constraint.""" refs = [pt] if pt2 is None else [pt, pt2] return SketchConstraint( @@ -414,7 +413,7 @@ def Angle(elem1: str, elem2: str, value: float, **kwargs) -> SketchConstraint: ) -def Symmetric(elem1: Union[str, PointRef], elem2: Union[str, PointRef], +def Symmetric(elem1: str | PointRef, elem2: str | PointRef, axis: str, **kwargs) -> SketchConstraint: """Create a symmetric constraint about a line axis.""" return SketchConstraint( diff --git a/sketch_canonical/document.py b/sketch_canonical/document.py index 47f3694..3bdf548 100644 --- a/sketch_canonical/document.py +++ b/sketch_canonical/document.py @@ -2,11 +2,10 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Optional -from .types import Point2D, PointRef, PointType, ElementPrefix -from .primitives import SketchPrimitive, Line, Arc, Circle, Point, Spline from .constraints import SketchConstraint +from .primitives import Arc, Circle, Line, Point, SketchPrimitive, Spline +from .types import ElementPrefix, Point2D, PointRef, PointType class SolverStatus(Enum): @@ -132,7 +131,7 @@ def remove_primitive(self, element_id: str) -> bool: self.solver_status = SolverStatus.DIRTY return True - def get_primitive(self, element_id: str) -> Optional[SketchPrimitive]: + def get_primitive(self, element_id: str) -> SketchPrimitive | None: """Get a primitive by its ID.""" return self.primitives.get(element_id) @@ -173,7 +172,7 @@ def remove_constraint(self, constraint_id: str) -> bool: return True return False - def get_constraint(self, constraint_id: str) -> Optional[SketchConstraint]: + def get_constraint(self, constraint_id: str) -> SketchConstraint | None: """Get a constraint by its ID.""" for c in self.constraints: if c.id == constraint_id: diff --git a/sketch_canonical/primitives.py b/sketch_canonical/primitives.py index cd14dc2..e03146c 100644 --- a/sketch_canonical/primitives.py +++ b/sketch_canonical/primitives.py @@ -1,11 +1,10 @@ """Geometry primitives for the canonical sketch schema.""" -from dataclasses import dataclass, field -from typing import Optional -from abc import ABC, abstractmethod import math +from abc import ABC, abstractmethod +from dataclasses import dataclass, field -from .types import Point2D, Vector2D, PointType +from .types import Point2D, PointType, Vector2D @dataclass @@ -15,7 +14,7 @@ class SketchPrimitive(ABC): construction: bool = False # True = reference geometry only # Metadata for reconstruction workflow - source: Optional[str] = None # Origin: "fitted", "user", "inferred" + source: str | None = None # Origin: "fitted", "user", "inferred" confidence: float = 1.0 # Fitting confidence (0-1) @abstractmethod @@ -237,7 +236,7 @@ class Spline(SketchPrimitive): degree: int = 3 control_points: list[Point2D] = field(default_factory=list) knots: list[float] = field(default_factory=list) - weights: Optional[list[float]] = None # None = non-rational (uniform weights) + weights: list[float] | None = None # None = non-rational (uniform weights) periodic: bool = False is_fit_spline: bool = False # True = control_points are fit points diff --git a/sketch_canonical/serialization.py b/sketch_canonical/serialization.py index c663f52..ec1a313 100644 --- a/sketch_canonical/serialization.py +++ b/sketch_canonical/serialization.py @@ -1,14 +1,12 @@ """JSON serialization and deserialization for the canonical sketch schema.""" import json -from typing import Any, Optional, Union +from typing import Any -from .types import Point2D, PointRef, PointType -from .primitives import SketchPrimitive, Line, Arc, Circle, Point, Spline -from .constraints import ( - SketchConstraint, ConstraintType, ConstraintStatus -) +from .constraints import ConstraintStatus, ConstraintType, SketchConstraint from .document import SketchDocument, SolverStatus +from .primitives import Arc, Circle, Line, Point, SketchPrimitive, Spline +from .types import Point2D, PointRef, PointType class SketchEncoder(json.JSONEncoder): @@ -30,7 +28,7 @@ def default(self, obj: Any) -> Any: return super().default(obj) -def sketch_to_json(sketch: SketchDocument, indent: Optional[int] = 2) -> str: +def sketch_to_json(sketch: SketchDocument, indent: int | None = 2) -> str: """ Serialize a sketch document to JSON string. @@ -315,7 +313,7 @@ def _parse_point(data: list) -> Point2D: # File I/O utilities -def save_sketch(sketch: SketchDocument, filepath: str, indent: Optional[int] = 2) -> None: +def save_sketch(sketch: SketchDocument, filepath: str, indent: int | None = 2) -> None: """ Save a sketch document to a JSON file. @@ -338,5 +336,5 @@ def load_sketch(filepath: str) -> SketchDocument: Returns: Loaded SketchDocument """ - with open(filepath, 'r', encoding='utf-8') as f: + with open(filepath, encoding='utf-8') as f: return sketch_from_json(f.read()) diff --git a/sketch_canonical/types.py b/sketch_canonical/types.py index 2e8a3ae..9494230 100644 --- a/sketch_canonical/types.py +++ b/sketch_canonical/types.py @@ -1,9 +1,8 @@ """Core data types for the canonical sketch schema.""" +import math from dataclasses import dataclass from enum import Enum -from typing import Optional -import math @dataclass(frozen=True) @@ -130,8 +129,8 @@ class PointRef: """ element_id: str point_type: PointType - parameter: Optional[float] = None # For ON_CURVE type - index: Optional[int] = None # For CONTROL type + parameter: float | None = None # For ON_CURVE type + index: int | None = None # For CONTROL type def __str__(self) -> str: if self.point_type == PointType.CONTROL: diff --git a/sketch_canonical/validation.py b/sketch_canonical/validation.py index 38a0fdd..9059fce 100644 --- a/sketch_canonical/validation.py +++ b/sketch_canonical/validation.py @@ -2,12 +2,11 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional -from .types import Point2D, PointRef, PointType -from .primitives import SketchPrimitive, Line, Arc, Circle, Point, Spline -from .constraints import SketchConstraint, ConstraintType, CONSTRAINT_RULES +from .constraints import CONSTRAINT_RULES, ConstraintType, SketchConstraint from .document import SketchDocument +from .primitives import Arc, Circle, Line, Point, SketchPrimitive, Spline +from .types import PointRef, PointType class ValidationSeverity(Enum): @@ -21,7 +20,7 @@ class ValidationSeverity(Enum): class ValidationIssue: """A single validation issue.""" severity: ValidationSeverity - element_id: Optional[str] # ID of the affected element, if applicable + element_id: str | None # ID of the affected element, if applicable message: str code: str # Machine-readable error code @@ -36,7 +35,7 @@ class ValidationResult: def __init__(self): self.issues: list[ValidationIssue] = [] - def add_error(self, message: str, code: str, element_id: Optional[str] = None): + def add_error(self, message: str, code: str, element_id: str | None = None): """Add an error-level issue.""" self.issues.append(ValidationIssue( severity=ValidationSeverity.ERROR, @@ -45,7 +44,7 @@ def add_error(self, message: str, code: str, element_id: Optional[str] = None): code=code )) - def add_warning(self, message: str, code: str, element_id: Optional[str] = None): + def add_warning(self, message: str, code: str, element_id: str | None = None): """Add a warning-level issue.""" self.issues.append(ValidationIssue( severity=ValidationSeverity.WARNING, @@ -54,7 +53,7 @@ def add_warning(self, message: str, code: str, element_id: Optional[str] = None) code=code )) - def add_info(self, message: str, code: str, element_id: Optional[str] = None): + def add_info(self, message: str, code: str, element_id: str | None = None): """Add an info-level issue.""" self.issues.append(ValidationIssue( severity=ValidationSeverity.INFO, @@ -175,7 +174,7 @@ def _validate_line(line: Line, result: ValidationResult, tolerance: float) -> No for coord in [line.start.x, line.start.y, line.end.x, line.end.y]: if not _is_finite(coord): result.add_error( - f"Line has non-finite coordinates", + "Line has non-finite coordinates", "LINE_INVALID_COORDS", line.id ) @@ -194,7 +193,7 @@ def _validate_arc(arc: Arc, result: ValidationResult, tolerance: float) -> None: for coord in coords: if not _is_finite(coord): result.add_error( - f"Arc has non-finite coordinates", + "Arc has non-finite coordinates", "ARC_INVALID_COORDS", arc.id ) @@ -214,7 +213,7 @@ def _validate_arc(arc: Arc, result: ValidationResult, tolerance: float) -> None: # Check for zero radius if r_start < tolerance: result.add_error( - f"Arc has zero radius", + "Arc has zero radius", "ARC_ZERO_RADIUS", arc.id ) @@ -235,7 +234,7 @@ def _validate_circle(circle: Circle, result: ValidationResult, tolerance: float) for coord in [circle.center.x, circle.center.y, circle.radius]: if not _is_finite(coord): result.add_error( - f"Circle has non-finite values", + "Circle has non-finite values", "CIRCLE_INVALID_VALUES", circle.id ) @@ -263,7 +262,7 @@ def _validate_point(point: Point, result: ValidationResult) -> None: for coord in [point.position.x, point.position.y]: if not _is_finite(coord): result.add_error( - f"Point has non-finite coordinates", + "Point has non-finite coordinates", "POINT_INVALID_COORDS", point.id ) diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 8a32712..461b5c1 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,32 +1,58 @@ """Tests for the adapter interface and FreeCAD adapter.""" -import pytest import math -from unittest.mock import Mock, MagicMock, patch -from typing import Any, Optional, Tuple +from typing import Any +from unittest.mock import MagicMock, Mock, patch + +import pytest +# Import vertex_map module (doesn't require FreeCAD) +from sketch_adapter_freecad.vertex_map import ( + VertexMap, + get_point_type_from_vertex, + get_vertex_index, +) from sketch_canonical import ( - # Types - Point2D, PointType, PointRef, - # Primitives - SketchPrimitive, Line, Arc, Circle, Point, Spline, + AdapterError, + Angle, + Arc, + Circle, + Coincident, + ConstraintError, # Constraints - ConstraintType, SketchConstraint, - Coincident, Tangent, Perpendicular, Parallel, Horizontal, Vertical, - Fixed, Distance, DistanceX, DistanceY, Radius, Diameter, Angle, Symmetric, - # Document - SketchDocument, SolverStatus, + ConstraintType, + Diameter, + Distance, + DistanceX, + DistanceY, + ExportError, + Fixed, + GeometryError, + Horizontal, + Line, + Parallel, + Perpendicular, + Point, + # Types + Point2D, + PointRef, + PointType, + Radius, # Adapter interface SketchBackendAdapter, - AdapterError, SketchCreationError, GeometryError, ConstraintError, ExportError, -) - -# Import vertex_map module (doesn't require FreeCAD) -from sketch_adapter_freecad.vertex_map import ( - VertexMap, get_vertex_index, get_point_type_from_vertex + SketchConstraint, + SketchCreationError, + # Document + SketchDocument, + # Primitives + SketchPrimitive, + SolverStatus, + Spline, + Symmetric, + Tangent, + Vertical, ) - # ============================================================================= # Adapter Interface Tests # ============================================================================= @@ -39,7 +65,7 @@ def __init__(self): self._primitives = [] self._constraints = [] - def create_sketch(self, name: str, plane: Optional[Any] = None) -> None: + def create_sketch(self, name: str, plane: Any | None = None) -> None: self._sketch_created = True self._name = name @@ -61,7 +87,7 @@ def add_constraint(self, constraint: SketchConstraint) -> bool: self._constraints.append(constraint) return True - def get_solver_status(self) -> Tuple[SolverStatus, int]: + def get_solver_status(self) -> tuple[SolverStatus, int]: return (SolverStatus.FULLY_CONSTRAINED, 0) def capture_image(self, width: int, height: int) -> bytes: diff --git a/tests/test_freecad_roundtrip.py b/tests/test_freecad_roundtrip.py index 79d340a..02b1263 100644 --- a/tests/test_freecad_roundtrip.py +++ b/tests/test_freecad_roundtrip.py @@ -8,21 +8,29 @@ import json import math -import os import shutil import subprocess -import sys import tempfile from pathlib import Path import pytest from sketch_canonical import ( - SketchDocument, Point2D, PointType, PointRef, - Line, Arc, Circle, Point, Spline, - Coincident, Tangent, Perpendicular, Parallel, Horizontal, Vertical, - Fixed, Distance, Radius, Diameter, Equal, Concentric, Length, Angle, - sketch_to_json, sketch_from_json, + Arc, + Circle, + Coincident, + Fixed, + Horizontal, + Line, + Point, + Point2D, + PointRef, + PointType, + Radius, + SketchDocument, + Spline, + Vertical, + sketch_to_json, ) diff --git a/tests/test_schema.py b/tests/test_schema.py index db35fc3..7fb9c41 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,35 +1,70 @@ """Tests for the canonical sketch schema.""" -import pytest -import math import json +import math + +import pytest from sketch_canonical import ( - # Types - Point2D, Vector2D, ElementId, PointType, PointRef, ElementPrefix, - # Primitives - SketchPrimitive, Line, Arc, Circle, Point, Spline, + Angle, + Arc, + Circle, + Coincident, + Collinear, + Concentric, + ConstraintStatus, # Constraints - ConstraintType, ConstraintStatus, SketchConstraint, CONSTRAINT_RULES, - Coincident, Tangent, Perpendicular, Parallel, Concentric, - Equal, Collinear, Horizontal, Vertical, Fixed, - Distance, DistanceX, DistanceY, Length, Radius, Diameter, Angle, - Symmetric, MidpointConstraint, + ConstraintType, + Diameter, + Distance, + DistanceX, + DistanceY, + ElementId, + Equal, + Fixed, + Horizontal, + Length, + Line, + MidpointConstraint, + Parallel, + Perpendicular, + Point, + # Types + Point2D, + PointRef, + PointType, + Radius, + SketchConstraint, # Document - SketchDocument, SolverStatus, - # Validation - validate_sketch, validate_primitive, validate_constraint, - ValidationResult, ValidationSeverity, ValidationIssue, DEFAULT_TOLERANCE, + SketchDocument, # Serialization SketchEncoder, - sketch_to_json, sketch_from_json, sketch_to_dict, dict_to_sketch, - primitive_to_dict, dict_to_primitive, - constraint_to_dict, dict_to_constraint, - point_ref_to_dict, dict_to_point_ref, - save_sketch, load_sketch, + # Primitives + SolverStatus, + Spline, + Symmetric, + Tangent, + ValidationResult, + Vector2D, + Vertical, + constraint_to_dict, + dict_to_constraint, + dict_to_point_ref, + dict_to_primitive, + dict_to_sketch, + load_sketch, + point_ref_to_dict, + primitive_to_dict, + save_sketch, + sketch_from_json, + sketch_to_dict, + sketch_to_json, + validate_constraint, + validate_primitive, + # Validation + validate_sketch, ) - # ============================================================================= # Types Tests # =============================================================================