Skip to content

Commit 20fc93a

Browse files
Fixed Fusion demo sketch handling.
1 parent 623c093 commit 20fc93a

File tree

3 files changed

+445
-98
lines changed

3 files changed

+445
-98
lines changed

sketch_adapter_fusion/adapter.py

Lines changed: 226 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)