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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
"""Assembly layout and ray casts, offline: the parts of a scope with their root-space boxes, joints and rigid
groups as part paths, rays cast in each occurrence's own coordinates, budgets that always make progress, the
inspect-like guard (and no revision digest unless one is asked for), and which of the two is journaled."""
import importlib
import json
import math
from types import SimpleNamespace as NS
import unittest
from unittest.mock import patch
from test_mechanical import Collection, adsk, isolated_history
import mech_exe
m = importlib.import_module('mechanical_test_commands.mechanical')
core = importlib.import_module('mechanical_test_commands.mechanical.core')
layout = importlib.import_module('mechanical_test_commands.mechanical.layout')
# ---- geometry fakes: Fusion's cm, rigid transforms, a box ray caster -------------------------------------
class Matrix:
"""A rigid transform in cm: rotation rows and a translation."""
def __init__(self, rotation=((1, 0, 0), (0, 1, 0), (0, 0, 1)), translation=(0.0, 0.0, 0.0)):
self.r, self.t = [list(row) for row in rotation], list(translation)
def copy(self):
return Matrix(self.r, self.t)
def invert(self):
rt = [[self.r[j][i] for j in range(3)] for i in range(3)]
self.r, self.t = rt, [-sum(rt[i][k] * self.t[k] for k in range(3)) for i in range(3)]
return True
def point(self, p):
return [sum(self.r[i][k] * p[k] for k in range(3)) + self.t[i] for i in range(3)]
def vector(self, v):
return [sum(self.r[i][k] * v[k] for k in range(3)) for i in range(3)]
class Point:
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
@staticmethod
def create(x, y, z):
return Point(x, y, z)
def transformBy(self, matrix):
self.x, self.y, self.z = matrix.point([self.x, self.y, self.z])
return True
def distanceTo(self, other):
return math.dist((self.x, self.y, self.z), (other.x, other.y, other.z))
class Vector(Point):
@staticmethod
def create(x, y, z):
return Vector(x, y, z)
def transformBy(self, matrix):
self.x, self.y, self.z = matrix.vector([self.x, self.y, self.z])
return True
class Points:
def __init__(self):
self.values = []
@staticmethod
def create():
return Points()
def add(self, value):
self.values.append(value)
return True
@property
def count(self):
return len(self.values)
def item(self, index):
return self.values[index]
class Part:
"""A component of boxes (local cm) that answers findBRepUsingRay like Fusion: one hit per body, the entry
point, or the exit point for a ray that starts inside."""
def __init__(self, name, boxes=(), fail=False):
self.name, self.boxes, self.fail, self.calls = name, [tuple(map(tuple, b)) for b in boxes], fail, []
def findBRepUsingRay(self, origin, direction, kind, tolerance, visible_only, points):
self.calls.append({"origin": (origin.x, origin.y, origin.z), "direction": (direction.x, direction.y, direction.z),
"visibleOnly": visible_only, "tolerance": tolerance})
if self.fail:
raise RuntimeError("3 : ray refused")
o, d = [origin.x, origin.y, origin.z], [direction.x, direction.y, direction.z]
n = math.sqrt(sum(v * v for v in d))
d = [v / n for v in d]
found = Points()
for lo, hi in self.boxes:
t0, t1 = -math.inf, math.inf
for k in range(3):
if abs(d[k]) < 1e-12:
if not lo[k] <= o[k] <= hi[k]:
break
continue
a, b = (lo[k] - o[k]) / d[k], (hi[k] - o[k]) / d[k]
t0, t1 = max(t0, min(a, b)), min(t1, max(a, b))
else:
if t1 >= max(t0, 0.0):
t = t0 if t0 >= 0 else t1
points.add(Point(*[o[k] + t * d[k] for k in range(3)]))
found.add(NS(name="body"))
return found
def body(lo, hi):
"""A body proxy whose root-space box is lo..hi mm."""
return NS(boundingBox=NS(minPoint=NS(x=lo[0] / 10, y=lo[1] / 10, z=lo[2] / 10), maxPoint=NS(x=hi[0] / 10, y=hi[1] / 10, z=hi[2] / 10)))
class Occ:
def __init__(self, name, path, bodies=(), children=(), visible=True, grounded=False, component=None, transform=None):
self.name, self.fullPathName, self.isLightBulbOn, self.isGrounded = name, path, visible, grounded
self.bRepBodies, self.childOccurrences = Collection(bodies), Collection(children)
self.component = component or Part(name.split(":")[0])
self.transform2 = transform or Matrix()
def comp(name, occurrences=(), joints=(), as_built=(), groups=(), bodies=0):
return NS(name=name, occurrences=Collection(occurrences), joints=Collection(joints), asBuiltJoints=Collection(as_built),
rigidGroups=Collection(groups), bRepBodies=Collection([object()] * bodies))
def app_for(root, name="Testbed"):
doc = NS(name=name, isValid=True, creationId="c-" + name)
design = NS(rootComponent=root, parentDocument=doc)
return NS(activeDocument=doc, activeProduct=design)
GEOMETRY = {"Point3D": Point, "Vector3D": Vector, "ObjectCollection": Points}
class Base(unittest.TestCase):
def setUp(self):
core._entities.clear()
core._documents.clear()
for name, value in GEOMETRY.items():
change = patch.object(adsk.core, name, value, create=True)
change.start()
self.addCleanup(change.stop)
change = patch.object(adsk.fusion, "BRepEntityTypes", NS(BRepBodyEntityType=0), create=True)
change.start()
self.addCleanup(change.stop)
self.digests = patch.object(core, "revision", return_value="r1")
self.revision = self.digests.start()
self.addCleanup(self.digests.stop)
# ---- assembly_layout -------------------------------------------------------------------------------------
class Layout(Base):
def panel(self):
"""A cell with a grounded base, a panel holding a rail, two blocks (one with a nested clip), a part
named with a "+", a hidden spare, and a part with no bodies."""
clip = Occ("Clip:1", "Cell:1+Panel:1+Block:1+Clip:1", [body((22, 0, 18), (24, 4, 26))])
block1 = Occ("Block:1", "Cell:1+Panel:1+Block:1", [body((20, 0, 10), (30, 10, 20))], [clip])
block2 = Occ("Block:2", "Cell:1+Panel:1+Block:2", [body((40, 0, 10), (50, 10, 20))])
rail = Occ("Rail:1", "Cell:1+Panel:1+Rail:1", [body((0, 0, 5), (100, 10, 10))])
plus = Occ("Top+Bottom:1", "Cell:1+Panel:1+Top+Bottom:1", [body((60, 0, 10), (70, 10, 30))])
spare = Occ("Spare:1", "Cell:1+Panel:1+Spare:1", [body((0, 0, 0), (1, 1, 1))], visible=False)
empty = Occ("Label:1", "Cell:1+Panel:1+Label:1")
joints = [NS(isSuppressed=False, occurrenceOne=NS(fullPathName="Rail:1"), occurrenceTwo=NS(fullPathName="Block:1+Clip:1")),
NS(isSuppressed=True, occurrenceOne=NS(fullPathName="Rail:1"), occurrenceTwo=NS(fullPathName="Block:2")),
NS(isSuppressed=False, occurrenceOne=NS(fullPathName="Top+Bottom:1"), occurrenceTwo=None)]
as_built = [NS(isSuppressed=False, occurrenceOne=NS(fullPathName="Block:2"), occurrenceTwo=NS(fullPathName="Rail:1"))]
group = NS(occurrences=Collection([NS(fullPathName="Block:1"), NS(fullPathName="Block:2+Screw:1")]))
panel_comp = comp("Panel", joints=joints, as_built=as_built, groups=[group], bodies=1)
panel = Occ("Panel:1", "Cell:1+Panel:1", [], [rail, block1, block2, plus, spare, empty], component=panel_comp)
base = Occ("Base:1", "Cell:1+Base:1", [body((-10, -10, -5), (110, 20, 0))], grounded=True)
cell = Occ("Cell:1", "Cell:1", [], [base, panel], component=comp("Cell"))
root = comp("root", occurrences=[cell])
return app_for(root), panel
def layout(self, app, **args):
return layout.assembly_layout(app, {"op": "assembly_layout", **args})
def test_the_visible_parts_under_the_scope_with_root_space_boxes_from_their_bodies_and_children(self):
app, _ = self.panel()
data = self.layout(app, scope="Cell:1+Panel:1")
units = {u["path"]: u for u in data["units"]}
self.assertEqual(list(units), ["Cell:1+Panel:1+Rail:1", "Cell:1+Panel:1+Block:1", "Cell:1+Panel:1+Block:2",
"Cell:1+Panel:1+Top+Bottom:1", "Cell:1+Panel:1+Label:1"]) # the hidden spare is left out
self.assertEqual(units["Cell:1+Panel:1+Block:1"], {"path": "Cell:1+Panel:1+Block:1", "name": "Block:1", "component": "Block",
"grounded": False, "box": [[20.0, 0.0, 10.0], [30.0, 10.0, 26.0]], "children": 1})
self.assertIsNone(units["Cell:1+Panel:1+Label:1"]["box"])
self.assertEqual((data["scope"], data["scopeName"], data["document"], data["frameBodies"], data["hiddenParts"]),
("Cell:1+Panel:1", "Panel", "Testbed", 1, 1))
self.assertEqual(data["target"], {"documentId": core.active(app)[2], "revision": "r1"})
def test_joints_and_rigid_groups_name_the_parts_they_join(self):
app, _ = self.panel()
data = self.layout(app, scope="Cell:1+Panel:1")
self.assertEqual(data["joints"], [["Cell:1+Panel:1+Rail:1", "Cell:1+Panel:1+Block:1", "joint"], # the clip belongs to block 1
["Cell:1+Panel:1+Top+Bottom:1", "", "joint"], # a "+" in a name stays whole
["Cell:1+Panel:1+Block:2", "Cell:1+Panel:1+Rail:1", "asBuilt"]]) # the suppressed joint is left out
self.assertEqual(data["rigidGroups"], [["Cell:1+Panel:1+Block:1", "Cell:1+Panel:1+Block:2"]])
def test_the_root_is_the_scope_when_none_is_given(self):
app, _ = self.panel()
data = self.layout(app)
self.assertEqual([u["path"] for u in data["units"]], ["Cell:1"])
self.assertEqual(data["units"][0]["box"], [[-10.0, -10.0, -5.0], [110.0, 20.0, 30.0]])
self.assertEqual((data["scope"], data["scopeName"]), ("", "Testbed"))
def test_a_part_with_more_bodies_than_max_bodies_is_boxed_breadth_first_from_the_first_ones_and_says_so(self):
app, _ = self.panel()
data = self.layout(app, scope="Cell:1+Panel:1", maxBodies=1)
units = {u["path"]: u for u in data["units"]}
self.assertEqual(units["Cell:1+Panel:1+Block:1"]["box"], [[20.0, 0.0, 10.0], [30.0, 10.0, 20.0]]) # its own body, not the clip's
self.assertTrue(units["Cell:1+Panel:1+Block:1"]["boxCapped"])
self.assertNotIn("boxCapped", units["Cell:1+Panel:1+Block:2"])
self.assertEqual(data["maxBodies"], 1)
def test_a_missing_scope_is_refused(self):
app, _ = self.panel()
with self.assertRaises(core.MechanicalError) as error:
self.layout(app, scope="Cell:1+Nothing:1")
self.assertEqual(error.exception.code, "occurrence_missing")
def test_the_target_is_optional_and_checked_like_inspect(self):
app, _ = self.panel()
key = core.active(app)[2]
self.assertEqual(self.layout(app, target={"documentId": key})["target"], {"documentId": key, "revision": "r1"})
with self.assertRaises(core.MechanicalError) as error:
self.layout(app, target={"documentId": key, "revision": "r0"})
self.assertEqual(error.exception.code, "stale_revision")
with self.assertRaises(core.MechanicalError) as error:
self.layout(app, target={"documentId": "another document"})
self.assertEqual(error.exception.code, "wrong_document")
def test_a_scope_with_too_many_visible_parts_is_refused_with_a_way_out(self):
app, _ = self.panel()
with patch.object(layout, "MAX_UNITS", 2), self.assertRaises(core.MechanicalError) as error:
self.layout(app, scope="Cell:1+Panel:1")
self.assertEqual(error.exception.code, "layout_budget")
self.assertIn("subassembly", str(error.exception))
# ---- ray_cast --------------------------------------------------------------------------------------------
class Rays(Base):
"""A plate at the origin, a block lifted 1 cm and moved (2, 2) cm, a bar turned 90 degrees about z."""
def setUp(self):
super().setUp()
self.plate = Part("Plate", [((0, 0, 0), (10, 10, 1))])
self.block = Part("Block", [((0, 0, 0), (2, 2, 2))])
self.bar = Part("Bar", [((0, 0, 0), (4, 1, 1))])
turn = ((0, -1, 0), (1, 0, 0), (0, 0, 1))
self.occurrences = [Occ("Block:1", "Block:1", component=self.block, transform=Matrix(translation=(2, 2, 1))),
Occ("Plate:1", "Plate:1", component=self.plate),
Occ("Bar:1", "Bar:1", component=self.bar, transform=Matrix(turn, (6, 1, 1)))]
self.app = app_for(comp("root", occurrences=self.occurrences))
self.key = core.active(self.app)[2]
def cast(self, rays, paths=("Block:1", "Plate:1", "Bar:1"), **args):
return layout.ray_cast(self.app, {"op": "ray_cast", "target": {"documentId": self.key}, "unit": "mm",
"occurrences": list(paths), "rays": rays, **args})
@staticmethod
def ray(origin, direction, length, against):
return {"origin": list(origin), "direction": list(direction), "length": length, "against": list(against)}
def test_each_ray_meets_each_named_occurrence_in_its_own_coordinates_nearest_first(self):
data = self.cast([self.ray((30, 30, 100), (0, 0, -1), 200, [1, 0]), # plate named first, block nearer
self.ray((55, 30, 100), (0, 0, -1), 200, [2]), # the turned bar spans x 50..60, y 10..50
self.ray((30, 30, 100), (0, 0, -1), 75, [0, 1]), # the plate is past the ray's length
self.ray((30, 30, 20), (0, 0, 1), 200, [0])]) # starts inside the block: its exit face
self.assertEqual(data["hits"], [[[0, 70.0], [1, 90.0]], [[2, 80.0]], [[0, 70.0]], [[0, 10.0]]])
self.assertEqual((data["cast"], data["complete"], data["missing"], data["failed"], data["casts"]), (4, True, [], 0, 6))
first = self.block.calls[0]
self.assertEqual([round(v, 9) for v in first["origin"]], [1.0, 1.0, 9.0]) # world (3, 3, 10) cm in the block's frame
self.assertFalse(first["visibleOnly"]) # a component's own bodies report invisible
self.assertEqual(first["tolerance"], -1.0)
def test_distances_are_rounded_to_a_hundredth_of_a_millimetre(self):
data = self.cast([self.ray((30, 30, 100.004), (0, 0, -1), 200, [0]), self.ray((30, 30, 100.0051), (0, 0, -1), 200, [0])])
self.assertEqual(data["hits"], [[[0, 70.0]], [[0, 70.01]]])
def test_a_missing_occurrence_is_named_and_a_refused_cast_is_counted(self):
self.plate.fail = True
data = self.cast([self.ray((30, 30, 100), (0, 0, -1), 200, [0, 1, 3])], paths=("Block:1", "Plate:1", "Bar:1", "Ghost:1"))
self.assertEqual((data["hits"], data["missing"], data["failed"]), ([[[0, 70.0]]], [3], 1))
def test_the_budget_stops_between_rays_and_says_how_far_it_got(self):
ticks = iter([0.0, 20.0, 40.0, 41.0])
with patch.object(layout, "_clock", lambda: next(ticks)):
data = self.cast([self.ray((30, 30, 100), (0, 0, -1), 200, [0])] * 3, budgetSeconds=30)
self.assertEqual((data["cast"], data["complete"], data["stopped"], len(data["hits"])), (2, False, "budget", 2))
self.assertIn("first 2 of 3 rays", data["hint"])
def test_every_call_answers_at_least_one_ray(self):
ticks = iter([0.0, 99.0, 99.0, 99.0])
with patch.object(layout, "_clock", lambda: next(ticks)):
data = self.cast([self.ray((30, 30, 100), (0, 0, -1), 200, [0])] * 2, budgetSeconds=1)
self.assertEqual(data["cast"], 1)
with patch.object(layout, "MAX_REPLY_BYTES", 5):
data = self.cast([self.ray((30, 30, 100), (0, 0, -1), 200, [0, 1])] * 2)
self.assertEqual((data["cast"], data["stopped"]), (1, "reply_bytes"))
def test_bad_indexes_and_a_zero_direction_are_refused_before_any_cast(self):
for rays, text in (([self.ray((0, 0, 0), (0, 0, 1), 10, [3])], "index"), ([self.ray((0, 0, 0), (0, 0, 0), 10, [0])], "nonzero")):
with self.assertRaises(core.MechanicalError) as error:
self.cast(rays)
self.assertEqual(error.exception.code, "invalid_input")
self.assertIn(text, str(error.exception))
self.assertEqual(self.block.calls, [])
def test_no_revision_digest_unless_the_target_asks_for_one(self):
data = self.cast([self.ray((30, 30, 100), (0, 0, -1), 200, [0])])
self.assertEqual(data["target"], {"documentId": self.key})
self.revision.assert_not_called()
checked = layout.ray_cast(self.app, {"op": "ray_cast", "target": {"documentId": self.key, "revision": "r1"}, "unit": "mm", "rays": []})
self.assertEqual((checked["target"], checked["hits"], checked["complete"]), ({"documentId": self.key, "revision": "r1"}, [], True))
self.assertEqual(self.revision.call_count, 1)
for wanted, code in (({"documentId": self.key, "revision": "r0"}, "stale_revision"), ({"documentId": "elsewhere"}, "wrong_document")):
with self.assertRaises(core.MechanicalError) as error:
layout.ray_cast(self.app, {"op": "ray_cast", "target": wanted, "unit": "mm", "rays": []})
self.assertEqual(error.exception.code, code)
# ---- the exe's side: validation and journaling -----------------------------------------------------------
class ExeSide(Base):
def setUp(self):
super().setUp()
isolated_history(self)
self.app = Layout.panel(self)[0]
self.key = core.active(self.app)[2]
def test_both_operations_are_in_the_schema_and_only_the_layout_is_journaled(self):
self.assertEqual(len(m.SCHEMA), 73)
self.assertIn("assembly_layout", m.WRITES)
self.assertNotIn("ray_cast", m.WRITES)
# the exe decides by the schema: a write is an operation whose schema requires an operationId
self.assertIn("operationId", m.SCHEMA["assembly_layout"]["required"])
self.assertNotIn("operationId", m.SCHEMA["ray_cast"]["required"])
def test_the_layout_goes_through_the_journal_and_status_reads_it_back(self):
args = {"op": "assembly_layout", "sessionId": m.SESSION_ID, "operationId": "layout-1", "scope": "Cell:1+Panel:1"}
reply = self.exe.call(self.app, dict(args))
self.assertTrue(reply["success"], reply)
self.assertTrue(reply["durability"]["terminalRecorded"])
self.assertEqual(len(reply["data"]["units"]), 5)
status = self.exe.status({"sessionId": m.SESSION_ID, "operationId": "layout-1"})
self.assertEqual(status["state"], "succeeded")
self.assertEqual(self.exe.call(self.app, dict(args))["replayed"], True)
def test_a_ray_batch_is_answered_without_a_journal_record(self):
app = app_for(comp("root", occurrences=[Occ("Plate:1", "Plate:1", component=Part("Plate", [((0, 0, 0), (10, 10, 1))]))]))
reply = self.exe.call(app, {"op": "ray_cast", "target": {"documentId": core.active(app)[2]}, "unit": "mm", "occurrences": ["Plate:1"],
"rays": [{"origin": [30, 30, 100], "direction": [0, 0, -1], "length": 500, "against": [0]}]})
self.assertTrue(reply["success"], reply)
self.assertNotIn("durability", reply)
self.assertEqual(reply["data"]["hits"], [[[0, 90.0]]])
self.assertFalse(self.exe.store().path.exists()) # nothing was journaled
def test_the_exe_refuses_a_malformed_ray_before_fusion_sees_it(self):
good = {"origin": [0, 0, 0], "direction": [0, 0, 1], "length": 10, "against": [0]}
base = {"op": "ray_cast", "target": {"documentId": "d"}, "unit": "mm", "occurrences": ["A:1"]}
for bad in ({**good, "against": ["0"]}, {**good, "against": [1.5]}, {**good, "origin": [0, 0]}, {k: v for k, v in good.items() if k != "length"},
{**good, "length": 0}, {**good, "weight": 1}, {**good, "against": []}):
with self.assertRaises(mech_exe.MechanicalError) as error:
mech_exe.prepare_request({**base, "rays": [bad]}, m.SCHEMA, m.WRITES, m.SESSION_ID)
self.assertEqual(error.exception.code, "invalid_input", bad)
with self.assertRaises(mech_exe.MechanicalError):
mech_exe.prepare_request({**base, "rays": [good] * 8193}, m.SCHEMA, m.WRITES, m.SESSION_ID)
frozen, write = mech_exe.prepare_request({**base, "rays": [good]}, m.SCHEMA, m.WRITES, m.SESSION_ID)
self.assertFalse(write)
with self.assertRaises(mech_exe.MechanicalError) as error: # journaled: needs the live session and an operation ID
mech_exe.prepare_request({"op": "assembly_layout", "sessionId": m.SESSION_ID}, m.SCHEMA, m.WRITES, m.SESSION_ID)
self.assertEqual(error.exception.code, "invalid_input")
if __name__ == "__main__":
unittest.main()