@@ -134,22 +134,26 @@ def create_sketch(self, name: str, plane=None) -> None:
134134 except Exception as e :
135135 raise SketchCreationError (f"Failed to create sketch: { e } " ) from e
136136
137- def load_sketch (self , sketch : SketchDocument ) -> None :
137+ def load_sketch (self , sketch : SketchDocument , plane = None ) -> None :
138138 """Load a SketchDocument into a new Fusion 360 sketch.
139139
140140 Creates a new sketch and populates it with the primitives and
141141 constraints from the provided SketchDocument.
142142
143143 Args:
144144 sketch: The SketchDocument to load
145+ plane: Optional plane specification. Can be:
146+ - None: Uses XY construction plane
147+ - "XY", "XZ", "YZ": Standard construction planes
148+ - A Fusion 360 ConstructionPlane or BRepFace object
145149
146150 Raises:
147151 SketchCreationError: If sketch creation fails
148152 GeometryError: If geometry creation fails
149153 ConstraintError: If constraint creation fails
150154 """
151155 # Create the sketch
152- self .create_sketch (sketch .name )
156+ self .create_sketch (sketch .name , plane = plane )
153157
154158 # Add all primitives
155159 for _prim_id , primitive in sketch .primitives .items ():
@@ -194,6 +198,10 @@ def export_sketch(self) -> SketchDocument:
194198 self ._export_geometric_constraints (doc )
195199 self ._export_dimensional_constraints (doc )
196200
201+ # Synthesize coincident constraints from shared sketch points
202+ # (Fusion often merges coincident points rather than keeping explicit constraints)
203+ self ._synthesize_coincident_constraints (doc )
204+
197205 # Update solver status
198206 status , dof = self .get_solver_status ()
199207 doc .solver_status = status
@@ -296,22 +304,43 @@ def _add_point(self, point: Point) -> Any:
296304 def _add_spline (self , spline : Spline ) -> Any :
297305 """Add a spline to the sketch.
298306
299- Fusion 360 supports both fitted splines (through points) and
300- control-point-based splines via NurbsCurve3D. We use the NURBS
301- approach for precise control point specification.
307+ Fusion 360 supports multiple spline types:
308+ - sketchControlPointSplines: Editable control point splines (degree 3 or 5 only)
309+ - sketchFixedSplines: Non-editable NURBS splines (preserves exact geometry)
310+ - sketchFittedSplines: Interpolating splines through fit points (RETIRED for NURBS)
311+
312+ We prefer control point splines for degree 3/5 with uniform knots (more native),
313+ and fall back to fixed splines for other cases.
302314 """
303315 # Create a list of Point3D from the spline control points
304316 control_points = []
305317 for pole in spline .control_points :
306318 control_points .append (self ._point2d_to_point3d (pole ))
307319
308- # Extract knot vector and weights
309- knots = list (spline .knots )
310320 degree = spline .degree
321+
322+ # For degree 3 or 5 non-periodic splines without custom weights,
323+ # use native control point splines for better round-trip fidelity
324+ if degree in (3 , 5 ) and not spline .periodic and not spline .weights :
325+ try :
326+ # SplineDegrees enum: 3 = CubicSplineDegree, 5 = QuinticSplineDegree
327+ if degree == 3 :
328+ spline_degree = self ._adsk_fusion .SplineDegrees .CubicSplineDegree
329+ else :
330+ spline_degree = self ._adsk_fusion .SplineDegrees .QuinticSplineDegree
331+
332+ splines = self ._sketch .sketchCurves .sketchControlPointSplines
333+ return splines .add (control_points , spline_degree )
334+ except Exception :
335+ # Fall through to fixed spline approach
336+ pass
337+
338+ # For other splines (periodic, weighted, or other degrees),
339+ # use fixed splines which preserve exact NURBS geometry
340+ knots = list (spline .knots )
311341 weights = list (spline .weights ) if spline .weights else [1.0 ] * len (spline .control_points )
312342
313343 # Create the NURBS curve (transient geometry)
314- # NurbsCurve3D methods expect Python lists, not ObjectCollections
315344 if spline .weights :
316345 nurbs_curve = self ._adsk_core .NurbsCurve3D .createRational (
317346 control_points ,
@@ -328,9 +357,8 @@ def _add_spline(self, spline: Spline) -> Any:
328357 spline .periodic
329358 )
330359
331- # Add as a fitted spline using the NURBS curve
332- # Note: addByNurbsCurve is on sketchFittedSplines collection
333- splines = self ._sketch .sketchCurves .sketchFittedSplines
360+ # Use sketchFixedSplines (not the RETIRED sketchFittedSplines.addByNurbsCurve)
361+ splines = self ._sketch .sketchCurves .sketchFixedSplines
334362 return splines .addByNurbsCurve (nurbs_curve )
335363
336364 def _add_ellipse (self , ellipse : Ellipse ) -> Any :
@@ -945,8 +973,61 @@ def _add_angle(self, refs, value: float) -> bool:
945973 line1 = self ._get_entity_for_ref (refs [0 ])
946974 line2 = self ._get_entity_for_ref (refs [1 ])
947975
948- # Find intersection point for text placement
949- text_pt = self ._adsk_core .Point3D .create (0 , 0 , 0 )
976+ # Calculate text position in the angle "wedge" between the lines
977+ # This helps Fusion select the correct angle quadrant
978+ try :
979+ # Get line directions
980+ start1 = line1 .startSketchPoint .geometry
981+ end1 = line1 .endSketchPoint .geometry
982+ start2 = line2 .startSketchPoint .geometry
983+ end2 = line2 .endSketchPoint .geometry
984+
985+ dir1 = self ._adsk_core .Vector3D .create (end1 .x - start1 .x , end1 .y - start1 .y , 0 )
986+ dir2 = self ._adsk_core .Vector3D .create (end2 .x - start2 .x , end2 .y - start2 .y , 0 )
987+
988+ dir1 .normalize ()
989+ dir2 .normalize ()
990+
991+ # Find common point (intersection or shared endpoint)
992+ common_pt = None
993+ if abs (start1 .x - start2 .x ) < 0.001 and abs (start1 .y - start2 .y ) < 0.001 :
994+ common_pt = start1
995+ elif abs (start1 .x - end2 .x ) < 0.001 and abs (start1 .y - end2 .y ) < 0.001 :
996+ common_pt = start1
997+ elif abs (end1 .x - start2 .x ) < 0.001 and abs (end1 .y - start2 .y ) < 0.001 :
998+ common_pt = end1
999+ elif abs (end1 .x - end2 .x ) < 0.001 and abs (end1 .y - end2 .y ) < 0.001 :
1000+ common_pt = end1
1001+ else :
1002+ # Use midpoint of line1 as fallback
1003+ common_pt = self ._adsk_core .Point3D .create (
1004+ (start1 .x + end1 .x ) / 2 ,
1005+ (start1 .y + end1 .y ) / 2 ,
1006+ 0
1007+ )
1008+
1009+ # Place text position in the angle bisector direction (between the two lines)
1010+ bisector_x = dir1 .x + dir2 .x
1011+ bisector_y = dir1 .y + dir2 .y
1012+ bisector_len = math .sqrt (bisector_x ** 2 + bisector_y ** 2 )
1013+ if bisector_len > 0.001 :
1014+ bisector_x /= bisector_len
1015+ bisector_y /= bisector_len
1016+ else :
1017+ # Lines are parallel or anti-parallel, use perpendicular
1018+ bisector_x = - dir1 .y
1019+ bisector_y = dir1 .x
1020+
1021+ # Offset from common point along bisector
1022+ offset = 0.5 # cm
1023+ text_pt = self ._adsk_core .Point3D .create (
1024+ common_pt .x + bisector_x * offset ,
1025+ common_pt .y + bisector_y * offset ,
1026+ 0
1027+ )
1028+ except Exception :
1029+ # Fallback to origin
1030+ text_pt = self ._adsk_core .Point3D .create (0 , 0 , 0 )
9501031
9511032 dim = dims .addAngularDimension (line1 , line2 , text_pt )
9521033 dim .parameter .value = angle_rad
@@ -1418,13 +1499,19 @@ def _export_points(self, doc: SketchDocument) -> None:
14181499
14191500 def _export_splines (self , doc : SketchDocument ) -> None :
14201501 """Export all splines from the sketch."""
1421- # Export fitted splines
1502+ # Export control point splines (native Fusion splines with control points)
1503+ ctrl_pt_splines = self ._sketch .sketchCurves .sketchControlPointSplines
1504+ for i in range (ctrl_pt_splines .count ):
1505+ spline = ctrl_pt_splines .item (i )
1506+ self ._export_single_spline (doc , spline )
1507+
1508+ # Export fitted splines (interpolating splines through fit points)
14221509 fitted_splines = self ._sketch .sketchCurves .sketchFittedSplines
14231510 for i in range (fitted_splines .count ):
14241511 spline = fitted_splines .item (i )
14251512 self ._export_single_spline (doc , spline )
14261513
1427- # Export fixed splines (NURBS)
1514+ # Export fixed splines (non-editable NURBS splines )
14281515 fixed_splines = self ._sketch .sketchCurves .sketchFixedSplines
14291516 for i in range (fixed_splines .count ):
14301517 spline = fixed_splines .item (i )
@@ -1745,6 +1832,21 @@ def _convert_midpoint(self, constraint) -> SketchConstraint | None:
17451832 line = constraint .midPointCurve
17461833 point_id = self ._get_id_for_entity_or_parent (point )
17471834 line_id = self ._get_id_for_entity (line )
1835+
1836+ # If point lookup failed, try to find a matching standalone point by position
1837+ if not point_id and point :
1838+ try :
1839+ point_pos = point .geometry
1840+ for prim_id , entity in self ._id_to_entity .items ():
1841+ if hasattr (entity , "objectType" ) and "SketchPoint" in entity .objectType :
1842+ entity_pos = entity .geometry
1843+ if (abs (entity_pos .x - point_pos .x ) < 0.0001 and
1844+ abs (entity_pos .y - point_pos .y ) < 0.0001 ):
1845+ point_id = prim_id
1846+ break
1847+ except Exception :
1848+ pass
1849+
17481850 if point_id and line_id :
17491851 ref = self ._point_to_ref (point , point_id )
17501852 return SketchConstraint (
@@ -1921,3 +2023,112 @@ def _convert_offset_dimension(self, dim, value: float) -> SketchConstraint | Non
19212023 value = value
19222024 )
19232025 return None
2026+
2027+ def _synthesize_coincident_constraints (self , doc : SketchDocument ) -> None :
2028+ """Synthesize coincident constraints from coincident sketch points.
2029+
2030+ Fusion 360 may not maintain explicit coincident constraints for points
2031+ that are at the same position. This method detects points at the same
2032+ position and generates coincident constraints to preserve the topological
2033+ relationships during round-trips.
2034+ """
2035+ # Build a list of all point references with their positions
2036+ # Each entry is (x, y, PointRef)
2037+ point_refs_with_pos : list [tuple [float , float , PointRef ]] = []
2038+
2039+ for prim_id , entity in self ._id_to_entity .items ():
2040+ obj_type = entity .objectType if hasattr (entity , "objectType" ) else ""
2041+
2042+ try :
2043+ if "SketchLine" in obj_type :
2044+ start_pt = entity .startSketchPoint
2045+ end_pt = entity .endSketchPoint
2046+ if start_pt :
2047+ pos = start_pt .geometry
2048+ point_refs_with_pos .append ((pos .x , pos .y , PointRef (prim_id , PointType .START )))
2049+ if end_pt :
2050+ pos = end_pt .geometry
2051+ point_refs_with_pos .append ((pos .x , pos .y , PointRef (prim_id , PointType .END )))
2052+
2053+ elif "SketchArc" in obj_type :
2054+ start_pt = entity .startSketchPoint
2055+ end_pt = entity .endSketchPoint
2056+ center_pt = entity .centerSketchPoint
2057+ if start_pt :
2058+ pos = start_pt .geometry
2059+ point_refs_with_pos .append ((pos .x , pos .y , PointRef (prim_id , PointType .START )))
2060+ if end_pt :
2061+ pos = end_pt .geometry
2062+ point_refs_with_pos .append ((pos .x , pos .y , PointRef (prim_id , PointType .END )))
2063+ if center_pt :
2064+ pos = center_pt .geometry
2065+ point_refs_with_pos .append ((pos .x , pos .y , PointRef (prim_id , PointType .CENTER )))
2066+
2067+ elif "SketchCircle" in obj_type :
2068+ center_pt = entity .centerSketchPoint
2069+ if center_pt :
2070+ pos = center_pt .geometry
2071+ point_refs_with_pos .append ((pos .x , pos .y , PointRef (prim_id , PointType .CENTER )))
2072+
2073+ elif "SketchPoint" in obj_type :
2074+ pos = entity .geometry
2075+ point_refs_with_pos .append ((pos .x , pos .y , PointRef (prim_id , PointType .CENTER )))
2076+ except Exception :
2077+ continue
2078+
2079+ # Track which coincident pairs we've already seen (to avoid duplicates)
2080+ existing_coincidents : set [tuple [str , str , str , str ]] = set ()
2081+
2082+ # Check existing coincident constraints in the document
2083+ for constraint in doc .constraints :
2084+ if constraint .constraint_type == ConstraintType .COINCIDENT :
2085+ refs = constraint .references
2086+ if len (refs ) == 2 :
2087+ ref1 , ref2 = refs
2088+ if isinstance (ref1 , PointRef ) and isinstance (ref2 , PointRef ):
2089+ key = tuple (sorted ([
2090+ (ref1 .element_id , ref1 .point_type .value ),
2091+ (ref2 .element_id , ref2 .point_type .value )
2092+ ]))
2093+ existing_coincidents .add ((key [0 ][0 ], key [0 ][1 ], key [1 ][0 ], key [1 ][1 ]))
2094+
2095+ # Group points by position (within tolerance)
2096+ tolerance = 0.0001 # cm (Fusion internal units)
2097+ position_groups : dict [tuple [float , float ], list [PointRef ]] = {}
2098+
2099+ for x , y , ref in point_refs_with_pos :
2100+ # Find existing group within tolerance
2101+ found_group = None
2102+ for (gx , gy ) in position_groups .keys ():
2103+ if abs (x - gx ) < tolerance and abs (y - gy ) < tolerance :
2104+ found_group = (gx , gy )
2105+ break
2106+
2107+ if found_group :
2108+ position_groups [found_group ].append (ref )
2109+ else :
2110+ position_groups [(x , y )] = [ref ]
2111+
2112+ # Generate coincident constraints for points at the same position
2113+ for _pos , refs in position_groups .items ():
2114+ if len (refs ) > 1 :
2115+ # Multiple primitives have points at this position
2116+ # Chain them: ref[0]-ref[1], ref[1]-ref[2], etc.
2117+ for i in range (len (refs ) - 1 ):
2118+ ref1 = refs [i ]
2119+ ref2 = refs [i + 1 ]
2120+
2121+ # Create normalized key to check for duplicates
2122+ key = tuple (sorted ([
2123+ (ref1 .element_id , ref1 .point_type .value ),
2124+ (ref2 .element_id , ref2 .point_type .value )
2125+ ]))
2126+ constraint_key = (key [0 ][0 ], key [0 ][1 ], key [1 ][0 ], key [1 ][1 ])
2127+
2128+ if constraint_key not in existing_coincidents :
2129+ doc .add_constraint (SketchConstraint (
2130+ id = self ._generate_constraint_id (),
2131+ constraint_type = ConstraintType .COINCIDENT ,
2132+ references = [ref1 , ref2 ]
2133+ ))
2134+ existing_coincidents .add (constraint_key )
0 commit comments