app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
"""Bounded geometry selection, engineering measurements and construction axes."""
import math
import adsk.core
import adsk.fusion
from .core import (MAX_ENTITIES, UNITS, in_root_context, items, number, point_mm, ref, require,
resolve, same, target)
from .readback import concrete_geometry, geometry_data
from .sketches import component, named, point
def native(entity):
return getattr(entity, "nativeObject", None) or entity
def owner(entity):
entity = native(entity)
if hasattr(entity, "body"):
return entity.body.parentComponent
if hasattr(entity, "parentSketch"):
return entity.parentSketch.parentComponent
return getattr(entity, "parentComponent", None) or getattr(entity, "component", None)
def root_entity(design, entity):
"""Never guess which instance of a non-root definition the caller means."""
if entity.objectType.endswith("::Occurrence"):
require(in_root_context(design, entity),
"root_context_required", "Occurrence must be resolved in the root assembly context.")
else:
context = getattr(entity, "assemblyContext", None)
require(context is not None or same(owner(entity), design.rootComponent),
"occurrence_required", "Root-space geometry in a child component requires an occurrence-context reference.")
if context is not None:
require(in_root_context(design, context),
"root_context_required", "Geometry context must be a root assembly occurrence.")
return entity
def finite_point(value):
require(value is not None, "measurement_failed", "Native measurement has no witness point.")
result = point_mm(value)
require(all(math.isfinite(v) for v in result), "measurement_failed", "Native witness point is not finite.")
return result
def minimum(manager, first, second):
result = manager.measureMinimumDistance(first, second)
require(result is not None and result.isValid, "measurement_failed", "Native minimum-distance measurement failed.")
distance = number(result.value, "native distance") * 10
require(distance >= 0, "measurement_failed", "Native minimum distance is negative.")
points = [finite_point(result.positionOne), finite_point(result.positionTwo)]
separation = math.dist(*points)
require(math.isclose(distance, separation, abs_tol=1e-5, rel_tol=1e-7),
"measurement_failed", "Native distance does not agree with its witness positions.")
return distance, points
MEASURABLE = ("Occurrence", "BRepBody", "BRepFace", "BRepEdge", "BRepVertex",
"ConstructionPlane", "ConstructionAxis", "ConstructionPoint", "SketchPoint",
"SketchLine", "SketchArc", "SketchCircle", "SketchEllipse", "SketchEllipticalArc",
"SketchFittedSpline", "SketchControlPointSpline", "SketchFixedSpline")
POINT_TYPES = ("BRepVertex", "ConstructionPoint", "SketchPoint")
def angle_direction(entity):
# Sketch geometry is in sketch coordinates; worldGeometry includes the
# sketch transform and the resolved occurrence context.
geometry = concrete_geometry(entity.worldGeometry if entity.objectType.endswith("::SketchLine")
else entity.geometry)
kind = geometry.objectType.split("::")[-1]
if kind == "Plane":
vector, meaning = geometry.normal, "normal"
elif kind == "Line3D":
vector, meaning = geometry.startPoint.vectorTo(geometry.endPoint), "line"
else:
require(kind == "InfiniteLine3D", "measurement_failed", "Cannot establish the native angle direction.")
vector, meaning = geometry.direction, "line"
return {"kind": meaning, "vector": unit_vector([vector.x, vector.y, vector.z])}
def angle_witness(entities, value, points):
a = [points[0][i] - points[1][i] for i in range(3)]
b = [points[2][i] - points[1][i] for i in range(3)]
if math.hypot(*a) > 1e-12 and math.hypot(*b) > 1e-12:
expected = math.degrees(math.acos(max(-1, min(1, sum(x*y for x, y in zip(unit_vector(a), unit_vector(b)))))))
require(math.isclose(value, expected, abs_tol=1e-5), "measurement_failed", "Angle disagrees with native witness positions.")
return {"method": "native_points", "nativePointWitnessDegenerate": False}
# Fusion returns three zero points for valid parallel-plane angles. Keep
# those original points, and establish parallelism from native geometry.
# Never accept a degenerate three-point angle or synthesize witness points.
require(len(entities) == 2 and all(e.objectType.split("::")[-1] not in POINT_TYPES for e in entities),
"invalid_direction", "Point angle witness is degenerate.")
require(min(abs(value), abs(value - 180)) <= 1e-5,
"measurement_failed", "Degenerate native points do not establish this angle.")
directions = [angle_direction(entity) for entity in entities]
dot = sum(x*y for x, y in zip(directions[0]["vector"], directions[1]["vector"]))
cosine = min(1, abs(dot))
deviation = math.degrees(math.acos(cosine) if directions[0]["kind"] == directions[1]["kind"]
else math.asin(cosine))
require(deviation <= 1e-5, "measurement_failed", "Degenerate angle points disagree with native geometry directions.")
return {"method": "native_geometry_directions", "nativePointWitnessDegenerate": True,
"directions": directions, "parallelDeviationDegrees": deviation}
def measure(app, args):
_, design, value = target(app, args)
entities = [root_entity(design, resolve(design, value, item, MEASURABLE)) for item in args["entities"]]
mode = args["type"]
if mode == "distance":
require(len(entities) == 2, "invalid_input", "Distance requires exactly two entities.")
result, witnesses = minimum(app.measureManager, *entities)
validation = {"method": "native_points"}
unit = "mm"
else:
kinds = [e.objectType.split("::")[-1] for e in entities]
if any(kind in POINT_TYPES for kind in kinds):
require(len(entities) == 3 and all(kind in POINT_TYPES for kind in kinds),
"invalid_input", "Point angles require exactly three points, with the second as apex.")
else:
require(len(entities) == 2, "invalid_input", "Line/plane angles require two entities.")
for entity, kind in zip(entities, kinds):
geometry = getattr(entity, "geometry", None)
require(kind in ("ConstructionPlane", "ConstructionAxis", "SketchLine") or
kind == "BRepEdge" and geometry.objectType.endswith("::Line3D") or
kind == "BRepFace" and geometry.objectType.endswith("::Plane"),
"unsupported_geometry", "Angle inputs must be linear or planar, or three points.")
measured = app.measureManager.measureAngle(*entities)
require(measured is not None and measured.isValid, "measurement_failed", "Native angle measurement failed.")
result = math.degrees(number(measured.value, "native angle"))
witnesses = [finite_point(getattr(measured, name)) for name in ("positionOne", "positionTwo", "positionThree")]
require(0 <= result <= 180 + 1e-7, "measurement_failed", "Native angle is outside 0..180 degrees.")
validation = angle_witness(entities, result, witnesses)
unit = "deg"
return {"target": value, "type": mode, "value": result, "unit": unit,
"frame": {"space": "root", "lengthUnit": "mm"}, "witnessPoints": witnesses,
"witnessValidation": validation,
"entities": [ref(entity, value) for entity in entities]}
def unit_vector(vector):
length = math.sqrt(sum(number(v, "direction") ** 2 for v in vector))
require(length > 1e-12, "invalid_direction", "Direction or angle witness is degenerate.")
return [v / length for v in vector]
def transformed_direction(vector, context):
xyz = [vector.x, vector.y, vector.z]
if context is not None:
matrix = context.transform2.asArray()
xyz = [sum(matrix[row*4 + col] * xyz[col] for col in range(3)) for row in range(3)]
return unit_vector(xyz)
def query_row(entity, kind, context, value):
base = native(entity)
geometry = getattr(base, "geometry", None)
if geometry is not None: geometry = concrete_geometry(geometry)
row = {"ref": ref(base, value, context), "type": geometry.objectType.split("::")[-1] if geometry else "degenerate"}
direction = None
if kind == "faces":
row["area"] = base.area * 100
if geometry.objectType.endswith("::Plane"):
ok, direction = base.evaluator.getNormalAtPoint(base.pointOnFace)
require(ok, "geometry_unavailable", "Could not evaluate planar face normal.")
elif kind == "edges":
row["length"] = base.length * 10
if geometry and geometry.objectType.endswith("::Line3D"):
direction = geometry.startPoint.vectorTo(geometry.endPoint)
if geometry and hasattr(geometry, "radius"):
row["radius"] = geometry.radius * 10
if direction:
row["direction"] = transformed_direction(direction, context)
return row
def near_geometry(manager, entity, kind, pick, frame_context, design):
if kind == "vertices":
candidate = entity.createForAssemblyContext(frame_context) if frame_context else entity
if frame_context is None and not same(owner(entity), design.rootComponent):
# MeasureManager rejects native B-Rep in a child definition; vertices are transient points.
candidate = entity.geometry
else:
# Faces and edges are measured on a temporary copy of the native entity. MeasureManager
# rejects native B-Rep in a child definition, and on an occurrence-context proxy it measures
# to the face's underlying surface or the edge's underlying curve: a coplanar face or a
# collinear edge beside the pick read 0 mm away and passed the filter. The copy keeps the
# trimmed geometry; root space moves it by the occurrence's transform. Nothing is added to
# the design.
temporary = adsk.fusion.TemporaryBRepManager.get()
candidate = temporary.copy(native(entity))
require(candidate is not None, "measurement_failed", "Could not copy component geometry for measurement.")
if frame_context is not None:
require(temporary.transform(candidate, frame_context.transform2), "measurement_failed",
"Could not move the copied geometry into the root frame.")
distance, witnesses = minimum(manager, point(pick), candidate)
# Native temporary-body measurements can reverse the two witness positions.
# Bind the pick by coordinates instead of assuming positionTwo is on the CAD.
matching = [i for i, witness in enumerate(witnesses) if math.dist(pick, witness) <= 1e-5]
require(matching, "measurement_failed", "Native distance has no witness at the requested pick.")
return distance, witnesses[1 - matching[0]]
def geometry_query(app, args):
_, design, value = target(app, args)
body = resolve(design, value, args["body"], ("BRepBody",))
space, kind, filters = args["space"], args["kind"], args.get("filters", {})
if space == "root":
root_entity(design, body)
context = getattr(body, "assemblyContext", None)
frame_context = context if space == "root" else None
source = native(body)
selected, collection = [], getattr(source, kind)
require(collection.count <= MAX_ENTITIES, "inspection_budget", "Body topology exceeds query budget.")
allowed_filters = {"type", "near"} | ({"area", "radius", "direction"} if kind == "faces" else
{"length", "radius", "direction"} if kind == "edges" else set())
require(not (set(filters) - allowed_filters), "invalid_input", "Filter is not supported for the selected topology kind.")
for key in ("area", "radius", "length"):
if key in filters:
limits = filters[key]
require(limits.get("min", 0) <= limits.get("max", math.inf), "invalid_input", "Filter minimum exceeds maximum.")
direction_filter = filters.get("direction")
desired = unit_vector(direction_filter["vector"]) if direction_filter else None
for entity in items(collection):
# Reference context and filter frame are separate: component-space
# queries on an instance still return references to that exact instance.
row = query_row(entity, kind, frame_context, value)
row["ref"] = ref(entity, value, context)
if "type" in filters and row["type"] != filters["type"]: continue
if any(key in filters and (key not in row or not filters[key].get("min", 0) <= row[key] <= filters[key].get("max", math.inf))
for key in ("area", "radius", "length")): continue
if desired:
if "direction" not in row: continue
dot = sum(a*b for a, b in zip(desired, row["direction"]))
if direction_filter.get("orientation", "same") == "parallel": dot = abs(dot)
if dot < math.cos(math.radians(direction_filter.get("tolerance", 0.01))) - 1e-12: continue
if "near" in filters:
distance, nearest = near_geometry(app.measureManager, entity, kind, filters["near"]["point"], frame_context, design)
if distance > filters["near"]["tolerance"] + 1e-7: continue
row.update(distanceToPick=distance, nearestPoint=nearest)
selected.append(row)
require(len(selected) <= args.get("maxResults", 256), "query_budget", "Query result exceeds maxResults; narrow the filters.")
if args.get("selection", "one") == "one":
require(len(selected) == 1, "selection_empty" if not selected else "ambiguous_selection",
"Geometry query must select exactly one entity.", matchCount=len(selected))
return {"target": value, "matches": selected, "count": len(selected), "complete": True,
"frame": {"space": space, "component": ref(source.parentComponent, value),
"occurrencePath": context.fullPathName if context else None}, "units": UNITS}
def axis_data(axis, value=None):
result = {"name": axis.name, "geometry": geometry_data(axis.geometry), "space": "component",
"healthState": int(axis.healthState), "message": axis.errorOrWarningMessage,
"parametric": axis.isParametric, "lengthUnit": "mm"}
if value: result["ref"] = ref(axis, value)
return result
def construction_axis(design, value, args):
comp = component(design, value, args)
methods = {"edge": ("setByEdge", ("BRepEdge", "SketchLine", "SketchArc", "SketchCircle"), 1),
"circular_face": ("setByCircularFace", ("BRepFace",), 1),
"two_planes": ("setByTwoPlanes", ("BRepFace", "ConstructionPlane"), 2),
"two_points": ("setByTwoPoints", POINT_TYPES, 2)}
method, allowed, count = methods[args["type"]]
require(len(args["entities"]) == count, "invalid_input", "Incorrect number of axis references.")
entities = [resolve(design, value, item, allowed) for item in args["entities"]]
require(count != 2 or not same(entities[0], entities[1]), "duplicate_entity", "Axis references must identify distinct entities.")
cross_component = any(not same(owner(entity), comp) or getattr(entity, "assemblyContext", None) for entity in entities)
inp = comp.constructionAxes.createInput()
if cross_component:
for entity in entities: root_entity(design, entity)
if not same(comp, design.rootComponent):
require(args.get("creationOccurrence"), "creation_occurrence_required", "Cross-component axes need an explicit occurrence of the target component.")
context = root_entity(design, resolve(design, value, args["creationOccurrence"], ("Occurrence",)))
require(same(context.component, comp), "wrong_component", "Creation occurrence does not instantiate the target component.")
inp.creationOccurrence = context
else:
require(not args.get("creationOccurrence"), "invalid_input", "Root axes do not use a creation occurrence.")
else:
require(not args.get("creationOccurrence"), "invalid_input", "Native same-component axis references do not use a creation occurrence.")
require(getattr(inp, method)(*entities), "axis_failed", "Fusion rejected the construction axis definition.")
axis = named(comp.constructionAxes.add(inp), args)
return lambda fresh: {"axis": axis_data(axis, fresh)}
READS = {"geometry_query": geometry_query, "measure": measure}
OPERATIONS = {"construction_axis": construction_axis}