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.
exploded_view_plan plans big designs by the moved scope #37
fix/exploded-plan-scale → masterexploded_view_plan: plan big designs by the moved scope instead of refusing over 256 occurrences
exploded_view_plan refused any design over 256 occurrences (plan_budget), so it never worked on a real
assembly. exploded_view_stills already handles a 4,579-occurrence assembly by tracking only the parts its
steps move. The plan now does the same.
What changed
- Moved scope past 256. Up to 256 occurrences the plan is the full inventory, unchanged
(
instanceScope: "all"). A bigger design gets the occurrences the steps move plus all their parents (instanceScope: "moved", withdesignInstancesand ascopeNote). Every other occurrence keeps its home pose plus its nearest listed parent's translation, so children follow their parent. Listed rows carry the same instance IDs, paths, home matrices, part numbers and poses as the full inventory (offline tests compare both modes row by row and pose by pose). - No whole-tree walks. Each listed occurrence is found by name, level by level (
itemByName, shared prefixes cached, names holding+handled likecore.occurrence_at). Parents still come from the native assembly context, never from the name. Moves are resolved once and reused bycompile_steps. - Cached reads. Part numbers are read once per component revision for the session (a cloud read of about
0.5 s each on a cloud assembly), reusing a
bomrun's cache. Component tokens are read once per component per plan, not once per instance. - Budgets with a refusal and a hint. Tracked occurrences (4,096) and poses (instances x sample times,
4,096) are checked before any per-instance read. Native reads stop at 40 s from the start, inside the relay's
60 s for a read, and the reply says how many instances were read and how long the revision check took. Every
bound refuses with
plan_budget, its numbers and ahint. - Referenced components (inserted from another saved design) are planned, as the revision digest already
treats them; a component from an unsaved foreign design still refuses (
wrong_component). Root bodies are counted, not capped at 256.
Related ops checked
exploded_view_renderimported the plan's 256 as its tracking bound; it now tracks 4,096 moved parts with their parents, like stills. ItscaptureandMoverlooked up parents by scanning all rows for each row (quadratic in tracked parts); both use dicts now. Behavior is unchanged (test added for nested carries).exploded_view_stillsalready had its own 4,096 bound; it benefits from the two dict fixes.- The exploded preview keeps its 64-instance / 128-body caps on purpose (it copies geometry). It now refuses a
moved-scope plan explicitly (
preview_budget, pointing at render/stills) instead of copying a partial scene.
Not changed
schema.json and request validation are unchanged, so no exe rebuild is needed and goldens regenerate with no
diff. The add-in Python carries the change.
Tests
- Base: master 3795faa (1.13.75).
- Offline:
python3 -m unittest discover454 OK (5 skipped), 13 new: moved scope without reading every occurrence, moved scope equal to the full plan for every listed row and pose (three step sets), name walks never read siblings, the same 55 counted native reads at 1,088 and 4,590 occurrences, a name holding+, a definition-local reference refused, up-front budgets before any instance read, the time budget in both scopes, part numbers read once per component revision and a bom read reused, referenced vs foreign components, root bodies past 256, the preview refusing a moved-scope plan, render's 4,096 bound and nested carries. tools/gen-mech-goldens.py: no diff.check-portable.shandcheck-naming.shpass. The Rust gate was not run: no Rust, schema or golden change (the exe embeds onlyschema.jsonfrom the add-in).- Generated files (goldens, contracts/describe.json) were regenerated on master 3795faa, with no change; if another of today's PRs merges first, they'll be regenerated again.
Live (Fusion 2705, scratch designs, modules loaded inside the running add-in, read-only)
Both modules (master's and this branch's) were loaded as copies inside the running add-in (public 1.13.61, Fusion 2705) and called on scratch designs built for the run and closed after it: a subassembly of 6 parts and 2 kits of 4 parts placed N times, positioned only. Plans are read-only: the revision was unchanged after every run. The verb path itself was not redeployed: the exe validates and passes the request through unchanged, so only the add-in module differs.
| design | op | master | this branch |
|---|---|---|---|
| 204 occurrences (full inventory) | 3 moves, 5 samples | 1.41 s (two captures 0.63 + 0.59 s) | 0.38 s (captures 0.10 + 0.10 s), identical rows, samples, steps and root group |
| 204 occurrences, forced moved scope | same | 0.16 s, 5 rows, rows and poses equal to the full plan's | |
| 1,088 occurrences | 3 moves | refuses in 0.3 s (plan_budget, over 256) |
0.61 s (0.65 s cold), 5 rows |
| 1,088 occurrences | 128 moves (136 rows) | refuses | 1.03 s, 297 KB; with 7 sample times 0.77 s, 509 KB |
| 1,088 occurrences, master with its cap lifted | 3 moves | 17.2 s (two whole-tree captures, 8.5 + 8.2 s), then refuses anyway: the reply passes 2 MiB | |
| 4,590 occurrences | 3 moves | refuses in 1.6 s | 3.0 s (3.4 s cold), 5 rows |
| 4,590 occurrences | 128 moves (136 rows) | refuses | 4.5 s, 297 KB; with 7 sample times 4.4 s, 510 KB |
Where the time goes in the branch's runs: the plan's own captures take 2 to 75 ms at 1,088 occurrences and
about 0.1 s at 4,590, so they do not grow with the design. The two revision checks every guarded operation runs
take 0.2 to 0.3 s each at 1,088 and 1.3 to 1.5 s each at 4,590, and they are most of the total. Resolving the
moves' references (core.resolve, one path walk per move) takes 0.27 s for 128 moves at 1,088 and 1.3 s at
4,590; that walk is shared code and could cache levels across moves later. Each session was one script: it built
the scratch design (1 s, 2 s and 43 s), measured, then closed it and made the starting document active again.
Suggested changelog line
exploded_view_plan plans designs over 256 occurrences: it lists the occurrences the steps move and their
parents (instanceScope: "moved"), found by name without walking the whole tree, with budgets that refuse
with a hint. exploded_view_render tracks up to 4,096 moved parts, like stills.
🤖 Generated with Claude Code
Diff Skip to comments
addin/AdomBridge/commands/mechanical/exploded_preview_prototype.py+5@@ -106,6 +106,11 @@ def matrix_values(entity): def source_geometry(design, value, plan): """Proxy visibility first, native definition geometry second; flat instances."""+ # a plan of a big design lists only the moved occurrences and their parents: copying those alone+ # would drop every other part from the presentation+ require(plan.get('instanceScope') == 'all',+ 'preview_budget', 'Preview copies every instance and supports at most 64; this design has %s occurrences. '+ 'Use exploded_view_render or exploded_view_stills for a big assembly.' % plan.get('designInstances')) require(len(plan['instances']) <= MAX_INSTANCES, 'preview_budget', 'Preview supports at most 64 physical instances.') rows = [(None, design.rootComponent, None)] for row in plan['instances']:
addin/AdomBridge/commands/mechanical/exploded_render.py+9−7@@ -10,7 +10,7 @@ import adsk.core import adsk.fusion from .core import SESSION_ID, concrete, current_target, digest, identity, ref, require, resolve, target-from .exploded_view import MAX_INSTANCES, compile_steps, matrix, request_arguments, sample_plan, text+from .exploded_view import MAX_TRACKED, SPLIT_HINT, compile_steps, matrix, request_arguments, sample_plan, text MAX_FRAMES = 3600 MOVED_NOTE = ("Fusion marks a document modified when a part moves, even after the move is reverted; the parts are back where "@@ -29,7 +29,7 @@ def to_matrix(pose): return matrix -def moved_scope(design, value, steps, limit=MAX_INSTANCES):+def moved_scope(design, value, steps, limit=MAX_TRACKED): """Full paths of the occurrences the steps move, and of their ancestors (for inherited motion). Children of a moved occurrence follow it by themselves, so they are not tracked.""" scope = set()@@ -38,7 +38,8 @@ def moved_scope(design, value, steps, limit=MAX_INSTANCES): path = text(resolve(design, value, move["occurrence"], ("Occurrence",)).fullPathName) parts = path.split("+") scope.update("+".join(parts[:n]) for n in range(1, len(parts) + 1))- require(len(scope) <= limit, "plan_budget", "The steps move more than %d occurrences (with their parents)." % limit)+ require(len(scope) <= limit, "plan_budget", "The steps move more than %d occurrences (with their parents)." % limit,+ hint=SPLIT_HINT.replace("plans", "runs"), tracked=len(scope), limit=limit) return scope @@ -54,17 +55,17 @@ def capture(design, value, scope): require(path not in found, "ambiguous_context", "Every root-context occurrence needs a unique full path.") found[path] = occurrence require(set(found) == scope, "invalid_context", "A moved occurrence or one of its parents is no longer in the design.")- rows, handles = [], {}+ rows, handles, ids = [], {}, {} for path in sorted(scope, key=lambda p: (p.count("+"), p)): occurrence = found[path] parent_path = path.rsplit("+", 1)[0] if "+" in path else None comp = concrete(occurrence.component) comp_id = identity(comp) instance_id = digest([SESSION_ID, value["documentId"], path, comp_id])- parent = next((r for r in rows if r["path"] == parent_path), None) rows.append({"instanceId": instance_id, "path": path, "occurrence": ref(occurrence, value),- "parentInstanceId": parent["instanceId"] if parent else None, "componentId": comp_id,+ "parentInstanceId": ids.get(parent_path), "componentId": comp_id, "componentName": text(comp.name, 256), "homeMatrix": matrix(occurrence.transform2)})+ ids[path] = instance_id # parents sort first, so a child finds its parent here (a dict, not a scan of the rows) handles[instance_id] = occurrence return {"instances": rows, "rootBodyGroup": {}}, handles @@ -79,11 +80,12 @@ class Mover: self.home = {row["instanceId"]: row["homeMatrix"] for row in self.rows} self.applied = {key: [0.0, 0.0, 0.0] for key in self.home} self.below = {key: [] for key in self.home}+ parent_of = {row["instanceId"]: row.get("parentInstanceId") for row in self.rows} for row in self.rows: parent = row.get("parentInstanceId") while parent is not None: self.below[parent].append(row["instanceId"])- parent = next(r.get("parentInstanceId") for r in self.rows if r["instanceId"] == parent)+ parent = parent_of[parent] def offset(self, key, pose): home = self.home[key]
addin/AdomBridge/commands/mechanical/exploded_view.py+231−41@@ -1,18 +1,38 @@-"""Read-only exploded assembly plans. Translation math never moves native objects."""+"""Read-only exploded assembly plans. Translation math never moves native objects.++A design of up to MAX_INSTANCES occurrences gets a plan of every root-context occurrence+(instanceScope "all"). A bigger design gets a plan of the occurrences the steps move and their+parents (instanceScope "moved"), each found by name level by level from the root, so a+4,579-occurrence assembly pays only for the parts its steps move. Every other occurrence keeps its+home pose plus the offset of its nearest listed parent: children follow their parent.""" import json import math import threading+import time +from .assembly_state import native_properties from .core import SESSION_ID, concrete, digest, identity, ref, require, resolve, same, target from .configurations import provenance, snapshot as configuration_snapshot -MAX_INSTANCES = 256+MAX_INSTANCES = 256 # a design with up to this many occurrences gets the whole inventory+MAX_TRACKED = 4096 # the most occurrences a moved-scope plan, render or still tracks (moved ones and their parents)+MAX_DEPTH = 32 MAX_STEPS = 32 MAX_MOVES = 128 MAX_SAMPLES = 64 MAX_POSES = 4096+MAX_ROOT_BODIES = 20000 MAX_OUTPUT_BYTES = 2 * 1024 * 1024+# native reads stop past this many seconds from the start: ab's relay gives a read about 60 s+BUDGET_SECONDS = 40.0 IDENTITY = [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 0.,0.,0.,1.]+SPLIT_HINT = ('Split the steps into several plans, or move a parent subassembly instead of each of its parts '+ '(children follow their parent).')+SAMPLES_HINT = 'Ask for fewer sampleTimes (both endpoints are always included), or split the steps into several plans.'+BUDGET_HINT = ('Run it again: the part numbers it read are kept for this session (on a cloud assembly each is a cloud read '+ 'of about 0.5 s), so the next run goes further. Or move fewer parts per plan.')+SCOPE_NOTE = ('instances lists the occurrences the steps move and their parents. Every other occurrence keeps its home pose '+ 'plus the translation of its nearest listed parent: children follow their parent.') def bounded(collection):@@ -44,8 +64,90 @@ def matrix(value): return out -def capture(design, value):+class Budget:+ """The native reads' time budget, counted from the start of the operation, which is what ab's relay+ waits on: a read that outlives it is lost to the caller and keeps Fusion busy. revision_seconds is+ the revision check every guarded operation runs before the plan reads any instance."""+ def __init__(self):+ self.started = time.perf_counter()+ self.revision_seconds = None++ def check(self, done, total):+ elapsed = time.perf_counter() - self.started+ require(elapsed <= BUDGET_SECONDS, 'plan_budget',+ 'The plan read %d of %d instances in %.1f s, past its %g s budget.' % (done, total, elapsed, BUDGET_SECONDS),+ hint=BUDGET_HINT, instancesRead=done, instances=total, elapsedSeconds=round(elapsed, 1),+ revisionSeconds=self.revision_seconds)+++_part_numbers = {} # (component identity, revisionId) -> part number+++def part_number(comp):+ """A component's part number, read once per component revision this session (each read is a cloud+ round trip of about 0.5 s on a cloud assembly, and repeated instances share their component); what+ a bom run read is reused."""+ key = (identity(comp), str(comp.revisionId))+ if key not in _part_numbers:+ known = native_properties(comp, read=False)+ if len(_part_numbers) > 50000:+ _part_numbers.clear()+ _part_numbers[key] = text(known[0] if known is not None else comp.partNumber, 256)+ return _part_numbers[key]+++def owned(comp, design):+ """A component of this design, or one inserted by reference from another saved design: read-only+ here, as the revision digest treats it."""+ if same(comp.parentDesign, design):+ return True+ source = getattr(comp.parentDesign, 'parentDocument', None)+ return not same(comp, design.rootComponent) and source is not None and getattr(source, 'dataFile', None) is not None+++def instance_row(design, value, occurrence, path, parent, parent_handle, refs):+ """One instance: its exact reference, component identity and home pose. Its parent is the native+ assembly context, checked against the parent row, never inferred from the path. refs holds one+ component reference per component, so repeated instances read their component's token once."""+ context=concrete(occurrence.assemblyContext)+ require((parent is None and context is None) or+ (parent is not None and context is not None and text(context.fullPathName)==parent['path']+ and same(context,parent_handle)),+ 'invalid_context', 'Occurrence parent context disagrees with the native hierarchy.')+ comp=concrete(occurrence.component)+ require(comp is not None and comp.isValid and owned(comp,design), 'wrong_component', 'Occurrence component belongs to another design.')+ comp_id=identity(comp)+ if comp_id not in refs:+ refs[comp_id]=ref(comp,value)+ return {'instanceId':digest([SESSION_ID,value['documentId'],path,comp_id]),'path':path,'occurrence':ref(occurrence,value),+ 'parentInstanceId':parent['instanceId'] if parent else None,+ 'componentId':comp_id,'component':refs[comp_id],'componentName':text(comp.name,256),+ 'partNumber':part_number(comp),'homeMatrix':matrix(occurrence.transform2)}+++def labelled(rows):+ # Numbers are presentation labels within this source-bound plan, not BOM quantities.+ labels={}+ for row in rows:+ labels.setdefault(row['componentId'],'C'+str(len(labels)+1).zfill(3))+ row['label']={'item':labels[row['componentId']],'instance':row['path'],+ 'componentId':row['componentId'],'partNumber':row['partNumber'],+ 'scope':'presentation component label; not an engineering BOM item number'}+ return rows+++def root_group(design, value):+ root=design.rootComponent+ root_bodies=root.bRepBodies.count+ require(type(root_bodies) is int and 0<=root_bodies<=MAX_ROOT_BODIES,'plan_budget','Root body inventory exceeds its bound.')+ return {'componentId':identity(root),'component':ref(root,value),'bodyCount':root_bodies,+ 'homeMatrix':list(IDENTITY),'selectable':False,'scope':'fixed root-owned direct bodies'}+++def capture(design, value, budget=None, refs=None): """Capture the actual root-context tree; never infer parents from names."""+ budget = Budget() if budget is None else budget+ refs = {} if refs is None else refs root = design.rootComponent candidates = bounded(root.allOccurrences) by_path = {}@@ -57,42 +159,100 @@ def capture(design, value): by_path[path]=occurrence rows, seen, handles = [], set(), {} def walk(occurrence,parent,depth):- require(depth<=32, 'plan_budget', 'Occurrence hierarchy exceeds 32 levels.')+ require(depth<=MAX_DEPTH, 'plan_budget', 'Occurrence hierarchy exceeds 32 levels.') path=text(occurrence.fullPathName) require(path in by_path and same(occurrence,by_path[path]) and path not in seen, 'ambiguous_context', 'Child traversal does not identify one exact root-context instance.') occurrence=by_path[path]- context=concrete(occurrence.assemblyContext)- require((parent is None and context is None) or- (parent is not None and context is not None and text(context.fullPathName)==parent['path']- and same(context,handles[parent['instanceId']])),- 'invalid_context', 'Occurrence parent context disagrees with the native hierarchy.')- comp=concrete(occurrence.component)- require(comp is not None and comp.isValid and same(comp.parentDesign,design), 'wrong_component', 'Occurrence component belongs to another design.')- comp_id=identity(comp)- instance_id=digest([SESSION_ID,value['documentId'],path,comp_id])- row={'instanceId':instance_id,'path':path,'occurrence':ref(occurrence,value),- 'parentInstanceId':parent['instanceId'] if parent else None,- 'componentId':comp_id,'component':ref(comp,value),'componentName':text(comp.name,256),- 'partNumber':text(comp.partNumber,256),'homeMatrix':matrix(occurrence.transform2)}- rows.append(row);seen.add(path);handles[instance_id]=occurrence+ budget.check(len(rows),len(by_path))+ row=instance_row(design,value,occurrence,path,parent,handles[parent['instanceId']] if parent else None,refs)+ rows.append(row);seen.add(path);handles[row['instanceId']]=occurrence for child in sorted(bounded(occurrence.childOccurrences),key=lambda item:text(item.fullPathName)): walk(child,row,depth+1) for occurrence in sorted(bounded(root.occurrences),key=lambda item:text(item.fullPathName)): walk(occurrence,None,1) require(seen==set(by_path), 'invalid_context', 'Native hierarchy did not cover every root-context occurrence.')- # Numbers are presentation labels within this source-bound plan, not BOM quantities.- labels={}- for row in rows:- labels.setdefault(row['componentId'],'C'+str(len(labels)+1).zfill(3))- row['label']={'item':labels[row['componentId']],'instance':row['path'],- 'componentId':row['componentId'],'partNumber':row['partNumber'],- 'scope':'presentation component label; not an engineering BOM item number'}- root_bodies=root.bRepBodies.count- require(type(root_bodies) is int and 0<=root_bodies<=MAX_INSTANCES,'plan_budget','Root body inventory exceeds its bound.')- root_group={'componentId':identity(root),'component':ref(root,value),'bodyCount':root_bodies,- 'homeMatrix':list(IDENTITY),'selectable':False,'scope':'fixed root-owned direct bodies'}- return {'instances':rows,'rootBodyGroup':root_group}, handles+ return {'instances':labelled(rows),'rootBodyGroup':root_group(design,value)}, handles+++def chain_at(design, path, found):+ """The root-context occurrences along a full path, outermost first, as (path, names, occurrence):+ found level by level by name (itemByName) instead of by reading every occurrence. A name can hold+ '+', so each level takes the shortest name that exists, as core.occurrence_at does. found caches+ the levels already walked (path -> occurrence), so parts in one subassembly read their shared+ parents once. None when the path does not resolve."""+ level, parts, names, chain = design.rootComponent.occurrences, path.split('+') if path else [], [], []+ try:+ while parts:+ for size in range(1, len(parts) + 1):+ name = '+'.join(parts[:size])+ key = '+'.join(names + [name])+ occurrence = found.get(key)+ if occurrence is None:+ occurrence = level.itemByName(name)+ if occurrence is not None:+ break+ else:+ return None+ found[key] = occurrence+ names.append(name)+ parts = parts[size:]+ chain.append((key, tuple(names), occurrence))+ if parts:+ level = occurrence.childOccurrences+ except (AttributeError, RuntimeError):+ return None+ return chain or None+++def moved_nodes(design, paths):+ """The occurrences at these full paths and all their parents: full path -> (names, occurrence,+ parent path). Nothing here reads every occurrence."""+ found, nodes = {}, {}+ for path in paths:+ chain = chain_at(design, path, found)+ require(chain is not None, 'invalid_context',+ 'A moved occurrence is not a root-context instance of this design, or one of its parents is gone.')+ parent = None+ for key, names, occurrence in chain:+ require(len(names) <= MAX_DEPTH, 'plan_budget', 'Occurrence hierarchy exceeds 32 levels.')+ nodes.setdefault(key, (names, occurrence, parent))+ parent = key+ require(len(nodes) <= MAX_TRACKED, 'plan_budget',+ 'The steps move %d occurrences with their parents; a plan tracks at most %d.' % (len(nodes), MAX_TRACKED),+ hint=SPLIT_HINT, tracked=len(nodes), limit=MAX_TRACKED)+ return nodes+++def capture_moved(design, value, nodes, budget=None, refs=None):+ """The plan's rows for just the moved occurrences and their parents, in the full inventory's order+ (each parent before its children, siblings by name). Rows read the same facts as the full capture."""+ budget = Budget() if budget is None else budget+ refs = {} if refs is None else refs+ rows, handles, index = [], {}, {}+ for key, (names, occurrence, parent_key) in sorted(nodes.items(), key=lambda item: item[1][0]):+ budget.check(len(rows), len(nodes))+ occurrence = concrete(occurrence)+ require(occurrence is not None and occurrence.isValid and occurrence.objectType=='adsk::fusion::Occurrence',+ 'plan_identity_unavailable', 'Invalid occurrence in the moved scope.')+ path = text(occurrence.fullPathName)+ require(path == key, 'ambiguous_context', 'A path does not identify one exact root-context instance.')+ parent = rows[index[parent_key]] if parent_key is not None else None+ row = instance_row(design, value, occurrence, path, parent, handles[parent['instanceId']] if parent else None, refs)+ index[key] = len(rows)+ rows.append(row)+ handles[row['instanceId']] = occurrence+ return {'instances': labelled(rows), 'rootBodyGroup': root_group(design, value)}, handles+++def resolve_moves(design, value, steps):+ """Each move's occurrence, resolved once (compile_steps reuses them), and their full paths."""+ resolved, paths = [], []+ for step in steps:+ occurrences = [resolve(design, value, move['occurrence'], ('Occurrence',)) for move in step['moves']]+ resolved.append(occurrences)+ paths.extend(text(occurrence.fullPathName) for occurrence in occurrences)+ return resolved, paths def request_arguments(args, max_steps=MAX_STEPS, max_moves=MAX_MOVES):@@ -133,14 +293,16 @@ def request_arguments(args, max_steps=MAX_STEPS, max_moves=MAX_MOVES): return total, sorted(set([0.,total]+times)), playback -def compile_steps(design,value,snapshot,handles,steps):+def compile_steps(design,value,snapshot,handles,steps,resolved=None):+ """resolved: each step's move occurrences already resolved, in order (resolve_moves); else each+ move's reference is resolved here.""" lookup={row['instanceId']:row for row in snapshot['instances']} by_path={row['path']:row for row in snapshot['instances']} result=[];start=0. for index,step in enumerate(steps): compiled=[];seen=set()- for move in step['moves']:- occurrence=resolve(design,value,move['occurrence'],('Occurrence',))+ for position,move in enumerate(step['moves']):+ occurrence=resolved[index][position] if resolved is not None else resolve(design,value,move['occurrence'],('Occurrence',)) path=text(occurrence.fullPathName);row=by_path.get(path) require(row is not None and same(occurrence,handles[row['instanceId']]), 'invalid_context','Selected reference is not this exact root-context instance.') key=row['instanceId'];require(key not in seen,'invalid_input','An instance may occur only once within a simultaneous step.');seen.add(key)@@ -155,12 +317,18 @@ def compile_steps(design,value,snapshot,handles,steps): return result +def pose_budget(instances, samples):+ require(instances*samples<=MAX_POSES,'plan_budget',+ 'Plan samples exceed %d instance poses (%d instances x %d sample times).'%(MAX_POSES,instances,samples),+ hint=SAMPLES_HINT,instances=instances,sampleTimes=samples,limit=MAX_POSES)++ def sample_plan(snapshot,steps,times,total,playback): """Pure deterministic sampling from immutable root home matrices."""- require(len(snapshot['instances'])*len(times)<=MAX_POSES,'plan_budget','Plan samples exceed 4096 instance poses.')+ pose_budget(len(snapshot['instances']),len(times)) results=[]- for time in times:- forward=time if playback=='explode' else total-time+ for time_point in times:+ forward=time_point if playback=='explode' else total-time_point own={row['instanceId']:[0.,0.,0.] for row in snapshot['instances']} progress=[] for step in steps:@@ -177,7 +345,7 @@ def sample_plan(snapshot,steps,times,total,playback): for axis,index in enumerate((3,7,11)):pose[index]+=offset[axis] require(all(math.isfinite(v) and abs(v)<=2e9 for v in pose),'plan_frame_invalid','Sample pose exceeds finite output bounds.') poses.append({'instanceId':key,'matrix':pose,'translationFromHome':offset})- results.append({'timeSeconds':time,'forwardTimeSeconds':forward,'stepProgress':progress,'poses':poses})+ results.append({'timeSeconds':time_point,'forwardTimeSeconds':forward,'stepProgress':progress,'poses':poses}) return results @@ -188,14 +356,31 @@ def exploded_view_plan(app,args): require(isinstance(wanted,dict) and isinstance(wanted.get('documentId'),str) and isinstance(wanted.get('revision'),str), 'invalid_input','Exploded planning requires exact target documentId and revision.') total,times,playback=request_arguments(args)+ budget=Budget() document,design,value=target(app,args)+ budget.revision_seconds=round(time.perf_counter()-budget.started,1) flags=(document.isSaved,document.isModified) require(all(type(v) is bool for v in flags),'plan_state_unavailable','Document flags are unavailable.') config=configuration_snapshot(design,strict=True)- snapshot,handles=capture(design,value)- steps=compile_steps(design,value,snapshot,handles,args['steps'])+ count=design.rootComponent.allOccurrences.count+ require(type(count) is int and count>=0,'plan_identity_unavailable','The occurrence count is unavailable.')+ whole=count<=MAX_INSTANCES+ refs={} # component references, read once per component for both captures+ if whole:+ pose_budget(count,len(times)) # before any per-instance read+ resolved=nodes=None+ else:+ resolved,paths=resolve_moves(design,value,args['steps'])+ nodes=moved_nodes(design,paths)+ pose_budget(len(nodes),len(times))+ def take(walked=None):+ if whole:+ return capture(design,value,budget,refs)+ return capture_moved(design,value,walked or moved_nodes(design,paths),budget,refs)+ snapshot,handles=take(nodes)+ steps=compile_steps(design,value,snapshot,handles,args['steps'],resolved) samples=sample_plan(snapshot,steps,times,total,playback)- after,_=capture(design,value)+ after,_=take() after_flags=(document.isSaved,document.isModified) require(after==snapshot and configuration_snapshot(design,strict=True)==config and all(type(v) is bool for v in after_flags) and after_flags==flags, 'plan_source_changed','Model instance, configuration or document state changed during planning.')@@ -205,15 +390,20 @@ def exploded_view_plan(app,args): 'configurationGraphDigest':digest(config),'units':{'translation':'mm','time':'s'},'space':'root/world', 'matrixLayout':'row-major; column-vector multiplication; translation indices 3,7,11', 'framePolicy':'directions normalized; axes fixed at home; parent translations inherited once; explicit child translations add',+ 'instanceScope':'all' if whole else 'moved','designInstances':count,+ **({} if whole else {'scopeNote':SCOPE_NOTE}), 'durationSeconds':total,'steps':steps,**snapshot} result={'kind':'PRESENTATION_PLAN','planId':digest(plan),'plan':plan,'playback':playback,'samples':samples, 'modelUnchanged':True,'nativeMoved':False,'graphicsCreated':False,'rendered':False,'storyboardCreated':False, 'bomQuantities':'not computed or changed; labels link component identities and exact instance paths', 'limits':['Explicit directions only; no automatic layout, rotation interpolation, collision or physical disassembly proof.',+ 'Over %d occurrences, instances lists only the moved occurrences and their parents (instanceScope moved).'%MAX_INSTANCES, 'Root-owned direct bodies are a fixed group and cannot be selected as occurrences.', 'No geometry copies, graphics preview, trails, video, native storyboard or archive-persistence claim.', 'Plan identity is bound to this session and model revision; no cross-session reference replay.']}- require(len(json.dumps(result,allow_nan=False).encode('utf-8'))<=MAX_OUTPUT_BYTES,'plan_budget','Plan exceeds its 2 MiB output bound.')+ size=len(json.dumps(result,allow_nan=False).encode('utf-8'))+ require(size<=MAX_OUTPUT_BYTES,'plan_budget','Plan is %d bytes, past its 2 MiB output bound.'%size,+ hint=SAMPLES_HINT,bytes=size,limit=MAX_OUTPUT_BYTES) return result
docs/EXPLODED-PREVIEWS.md+2@@ -4,6 +4,8 @@ This source candidate adds `exploded_preview_create`, `exploded_preview_sample`, The backend copies solid BRep geometry into a separate blank presentation Design. Its flat graphics groups use the planner's root/world transforms, converted from mm to cm once. Source occurrences, bodies, joints, grounding and visibility are never written. The prototype's exact native lease/copy contract is documented in [EXPLODED-PREVIEW.md](prototypes/EXPLODED-PREVIEW.md). +Because it copies every instance, a preview needs a plan of the whole inventory (`instanceScope: "all"`, which `exploded_view_plan` gives a design of up to 256 occurrences; the backend itself copies at most 64 instances and 128 bodies). A plan of a bigger design lists only the moved occurrences and their parents, and the preview refuses it with `preview_budget` before any copy. `exploded_view_render` and `exploded_view_stills` handle big assemblies.+ The shared revision reader initializes every bounded component mesh collection before capturing any native component revision ID. Native diagnosis on modified fixtures found that a first child collection acquisition can also advance ancestor IDs. All original revision fields and exact stale-target checks remain; old targets and committed creation receipts are never rewritten. This affects ordinary mechanical target issuance as well as preview preparation. The reader records the exact parent document's saved/modified flags before initialization, after it and after hashing. Invalid ownership, collection/read failures or changed flags refuse with bounded observations and `rollbackClaimed: false`; errors also attempt flag readback and preserve unknown observations. A changed dirty flag is never reset or hidden. A separate native function gate passed a fresh clean unsaved blank and a cold, already-modified local F3D control, with unchanged flags and exact cleanup. Saved/cloud-clean multi-component preservation remains unqualified. That gate applies to the shared revision reader, not this revised graphics lifecycle.
docs/EXPLODED-VIEWS.md+39−3@@ -63,6 +63,34 @@ they are not authoritative engineering item numbers. Engineering/purchasing quan are neither recalculated nor changed. Root-owned direct bodies are returned as a fixed root group and cannot be selected as occurrences in this slice. +### Designs over 256 occurrences++A design of up to 256 occurrences gets every root-context occurrence in+`plan.instances` (`instanceScope: "all"`). A bigger design gets the occurrences the+steps move and all their parents (`instanceScope: "moved"`); `designInstances` gives+the design's occurrence count and `scopeNote` the rule. Every other occurrence keeps+its home pose plus the translation of its nearest listed parent, so the children of a+moved subassembly follow it. Each listed occurrence is found by name, level by level+from the root (`itemByName`), so the plan's own reads never walk the whole tree; the+revision check that every guarded operation runs still reads each occurrence once. A+parent still comes from the native assembly context, never from the name. Listed rows+carry the same facts the full inventory gives: the same instance IDs, paths, home+matrices, part numbers and sampled poses.++A component inserted by reference from another saved design is planned like any+other (read-only here, as the revision digest treats it). Part numbers are read once+per component revision for the session, because each is a cloud read of about 0.5 s+on a cloud assembly; a `bom` run's reads are reused. An exploded preview copies every+instance, so it refuses a moved-scope plan; use `exploded_view_render` or+`exploded_view_stills` for a big assembly.++Measured on scratch designs of positioned subassemblies (Fusion 2705): at 1,088+occurrences a plan of 3 moves took 0.6 s and one of 128 moves (136 listed+occurrences) 1.0 s; at 4,590 occurrences, 3.0 s and 4.5 s. The plan's own reads took+about 0.1 s at either size; the two revision checks were most of the rest. Before+this, both designs refused, and lifting the 256 cap alone spent 17 s capturing the+1,088-occurrence tree twice before refusing a reply over 2 MiB.+ `planId` hashes the returned immutable plan; each sample includes its time, forward plan time, per-step progress and instance matrices. `playback: "assemble"` samples the same plan backwards. Both endpoints are always included; the last assembly@@ -71,9 +99,17 @@ home arrays. The plan is bound to one session/model revision and a complete supp configuration witness; incomplete configurations refuse, and no old-session native reference replay is enabled. No plan file is written automatically. -Limits are 256 instances, 32 hierarchy levels, 32 ordered steps, 128 moves in total,-3600 seconds total duration, 62 requested sample times plus the endpoints, 4096-instance poses and 2 MiB output. Individual moves are limited to 1e6 mm and home+Limits are 256 occurrences for the full inventory (past it, the moved scope), 4,096+tracked occurrences in a moved scope (the moved ones with their parents), 32+hierarchy levels, 32 ordered steps, 128 moves in total, 3600 seconds total duration,+62 requested sample times plus the endpoints, 4096 instance poses (listed instances+times sample times) and 2 MiB output. The native reads have a 40 s budget counted+from the start of the operation, inside the relay's 60 s for a read. Each bound+refuses with `plan_budget`, the numbers involved and a `hint`. The pose and tracked+bounds are checked before any per-instance read and say how to split the steps or+sample less. Past the time budget the reply says how many instances were read and how+long the revision check took; running the plan again goes further, because the part+numbers already read are kept. Individual moves are limited to 1e6 mm and home translations to 1e9 mm. Scaled, reflected, nonfinite or malformed native matrices, ambiguous instances and incomplete parent relationships refuse. Main-thread, session, document and revision guards precede native reads. A second instance/configuration
docs/MECHANICAL.md+9@@ -87,6 +87,15 @@ explicit child offsets add. `playback: "assemble"` reverses the same timeline an returns exact home poses at its endpoint. Optional titles and instructions accompany the step/progress sequence. See [the contract and limits](EXPLODED-VIEWS.md). +A design over 256 occurrences gets a plan of just the occurrences the steps move and their+parents (`instanceScope: "moved"`), each found by name level by level, never by walking the whole+tree; other occurrences keep their home pose and children follow their parent. Components inserted+by reference are planned too. Every bound refuses with `plan_budget` and a `hint`, and the native+reads stop at 40 s, inside the relay's 60 s for a read. Measured: 128 moves in a 4,590-occurrence+design planned in 4.5 s, most of it the revision checks every guarded operation runs.+`exploded_view_render` now tracks up to 4,096 moved parts with their parents, like+`exploded_view_stills` (it stopped at 256).+ This operation performs native reads and pure pose calculations only. It does not move occurrences, suppress joints, create graphics, change BOM quantities, create a storyboard, render images or export video. Native module-function qualification
docs/prototypes/EXPLODED-PREVIEW.md+1−1@@ -37,7 +37,7 @@ One graphics root contains **flat sibling** groups for the fixed root-owned bodi Copies are checked through the returned transient `CustomGraphicsBRepBody.bRepBody`: positive solid volume (cm³), local bounds (cm), face/edge counts and corresponding vertex coordinates (cm). Group transforms are read back after every finite sample. These observations supplement the native copying API contract; they do not prove appearance, material or texture fidelity, nor provide a complete shape-equivalence algorithm. Both root selection and child selection are disabled and verified. -Supported scope is at most 64 physical instances, 128 solid BRep copies, 4,096 faces/edges/vertices per body collection and 16,384 total copied vertices. All physical source occurrences and all contextual source bodies must be effectively visible. Suppression, hidden components/bodies, any instantiated/root mesh body, nonsolid BRep, invalid context or unavailable required getter refuses the entire preparation. No unsupported body is silently omitted. Empty assembly components are retained as groups. Sketches, construction geometry, decals, point clouds, existing custom graphics and other non-BRep scene objects are outside this explicitly **solid BRep presentation** scope; there is no full-scene completeness claim.+Supported scope is at most 64 physical instances, 128 solid BRep copies, 4,096 faces/edges/vertices per body collection and 16,384 total copied vertices. The plan must list every instance (`instanceScope: "all"`); a moved-scope plan of a design over 256 occurrences refuses before any copy. All physical source occurrences and all contextual source bodies must be effectively visible. Suppression, hidden components/bodies, any instantiated/root mesh body, nonsolid BRep, invalid context or unavailable required getter refuses the entire preparation. No unsupported body is silently omitted. Empty assembly components are retained as groups. Sketches, construction geometry, decals, point clouds, existing custom graphics and other non-BRep scene objects are outside this explicitly **solid BRep presentation** scope; there is no full-scene completeness claim. Effective visibility is checked on exact occurrence/body proxies before using the native body as copy input. Transient copies are never queried for `BRepBody.isVisible`, which the API does not support. Source geometry and revision are rechecked before/after effects. Source visibility and suppression are never changed to improve a preview.
tests/test_exploded_preview_prototype.py+10@@ -190,6 +190,16 @@ class PreviewTests(unittest.TestCase): self.assertEqual(self.f.design.rev, 'source-rev') self.assertGreater(len(effects), 5) + def test_a_moved_scope_plan_of_a_big_design_is_refused_before_any_copy(self):+ with patch.object(plan, 'MAX_INSTANCES', 0):+ generated = plan.exploded_view_plan(self.app, self.args)+ self.assertEqual(generated['plan']['instanceScope'], 'moved')+ with self.assertRaises(c.MechanicalError) as error:+ p.prepare(self.app, self.args, generated['planId'])+ self.assertEqual(error.exception.code, 'preview_budget')+ self.assertIn('exploded_view_stills', str(error.exception))+ self.assertEqual(self.root.customGraphicsGroups.count, 0)+ def test_native_top_group_parent_shape_does_not_replace_inventory_proof(self): preview = self.preview() preview.create()
tests/test_mechanical_exploded_render.py+35−4@@ -205,10 +205,41 @@ class Scope(unittest.TestCase): self.assertIsNone(rows[0]["parentInstanceId"]) def test_too_many_moved_parts_are_refused(self):- with unittest.mock.patch.object(r, "resolve", lambda d, v, ref, kinds: NS(fullPathName=ref)), \- self.assertRaises(core.MechanicalError) as error:- r.moved_scope(None, {}, [{"moves": [{"occurrence": "P%d:1" % i} for i in range(300)]}])- self.assertEqual(error.exception.code, "plan_budget")+ # the same bound as stills and moved-scope plans: 4,096 occurrences with their parents+ with unittest.mock.patch.object(r, "resolve", lambda d, v, ref, kinds: NS(fullPathName=ref)):+ self.assertEqual(len(r.moved_scope(None, {}, [{"moves": [{"occurrence": "Box:1+P%d:1" % i} for i in range(300)]}])), 301)+ with self.assertRaises(core.MechanicalError) as error:+ r.moved_scope(None, {}, [{"moves": [{"occurrence": "P%d:1" % i} for i in range(4097)]}])+ self.assertEqual((error.exception.code, error.exception.details["tracked"]), ("plan_budget", 4097))+ self.assertIn("parent subassembly", error.exception.details["hint"])+++class Carry(unittest.TestCase):+ def test_moving_a_parent_carries_its_tracked_children_and_grandchildren(self):+ sets = []++ class Occ:+ def __init__(self, key):+ self.key = key++ @property+ def transform2(self):+ return Pose(HOME)++ @transform2.setter+ def transform2(self, matrix):+ sets.append(self.key)+ rows = [{"instanceId": "G", "parentInstanceId": None, "homeMatrix": HOME},+ {"instanceId": "P", "parentInstanceId": "G", "homeMatrix": HOME},+ {"instanceId": "C", "parentInstanceId": "P", "homeMatrix": HOME}]+ mover = r.Mover({"instances": rows}, {key: Occ(key) for key in "GPC"})+ self.assertEqual(mover.below, {"G": ["P", "C"], "P": ["C"], "C": []})+ at = lambda x: [1., 0., 0., x, 0., 1., 0., 0., 0., 0., 1., 0., 0., 0., 0., 1.]+ with unittest.mock.patch.object(r, "to_matrix", lambda pose: pose):+ mover.apply({"G": at(10), "P": at(10), "C": at(10)}) # the parent's move carries both below it+ self.assertEqual((sets, mover.moves), (["G"], 1))+ mover.apply({"G": at(10), "P": at(10), "C": at(15)})+ self.assertEqual((sets, mover.moves), (["G", "C"], 2)) class Drift(unittest.TestCase):
tests/test_mechanical_exploded_view.py+198−6@@ -17,14 +17,14 @@ def pose(x=0,y=0,z=0,angle=0): def fixture():- design=Entity();root=Entity(objectType='adsk::fusion::Component',name='Root',partNumber='',entityToken='root',parentDesign=design,bRepBodies=Collection([object()]))+ design=Entity();root=Entity(objectType='adsk::fusion::Component',name='Root',partNumber='',entityToken='root',parentDesign=design,bRepBodies=Collection([object()]),revisionId='r1') design.rootComponent=root- shared=Entity(objectType='adsk::fusion::Component',name='Assembly',partNumber='DUP',entityToken='shared',parentDesign=design)- pin=Entity(objectType='adsk::fusion::Component',name='Pin',partNumber='DUP',entityToken='pin',parentDesign=design)+ shared=Entity(objectType='adsk::fusion::Component',name='Assembly',partNumber='DUP',entityToken='shared',parentDesign=design,revisionId='r1')+ pin=Entity(objectType='adsk::fusion::Component',name='Pin',partNumber='DUP',entityToken='pin',parentDesign=design,revisionId='r1') token_map={'root':[root],'shared':[shared],'pin':[pin]} def occ(path,comp,matrix,parent=None,native=None): token='occ-'+path if native is None else native.entityToken- value=Entity(objectType='adsk::fusion::Occurrence',entityToken=token,fullPathName=path,+ value=Entity(objectType='adsk::fusion::Occurrence',entityToken=token,fullPathName=path,name=path.split('+')[-1], component=comp,assemblyContext=parent,nativeObject=native,transform2=NS(asArray=lambda:list(matrix)),childOccurrences=Collection()) if token not in token_map:token_map[token]=[value] return value@@ -168,11 +168,143 @@ class Exploded(unittest.TestCase): with patch.object(plan,'MAX_POSES',1): with self.assertRaises(c.MechanicalError) as error:self.read() self.assertEqual(error.exception.code,'plan_budget')+ self.assertIn('sampleTimes',error.exception.details['hint']) with patch.object(plan,'MAX_OUTPUT_BYTES',10): with self.assertRaises(c.MechanicalError) as error:self.read() self.assertEqual(error.exception.code,'plan_budget')- self.f.root.allOccurrences=NS(count=257,item=lambda _:self.fail('Must not enumerate'))- with self.assertRaises(c.MechanicalError):self.read()+ self.assertGreater(error.exception.details['bytes'],10);self.assertIn('hint',error.exception.details)++ # -- designs over 256 occurrences: the moved occurrences and their parents ------------------------+ def big(self,count=4579):+ """The fixture read as a big design: reading every occurrence fails the test."""+ self.f.root.allOccurrences=NS(count=count,item=lambda _:self.fail('Must not read every occurrence'))++ def test_a_design_over_256_occurrences_plans_the_moved_scope_without_reading_every_occurrence(self):+ steps=[{'durationSeconds':2,'moves':[self.move(frame='local',distance=12)]}]+ full=self.read(steps=steps,sampleTimes=[1])+ self.big()+ result=self.read(steps=steps,sampleTimes=[1])+ rows=result['plan']['instances']+ self.assertEqual((full['plan']['instanceScope'],result['plan']['instanceScope']),('all','moved'))+ self.assertEqual((full['plan']['designInstances'],result['plan']['designInstances']),(4,4579))+ self.assertIn('children follow their parent',result['plan']['scopeNote']);self.assertNotIn('scopeNote',full['plan'])+ self.assertEqual([r['path'] for r in rows],['Assembly:1','Assembly:1+Pin:1'])+ self.assertIsNone(rows[0]['parentInstanceId']);self.assertEqual(rows[1]['parentInstanceId'],rows[0]['instanceId'])+ listed={r['instanceId'] for r in rows}+ self.assertEqual(rows,[r for r in full['plan']['instances'] if r['instanceId'] in listed])+ for mine,theirs in zip(result['samples'],full['samples']):+ self.assertEqual(mine['poses'],[p for p in theirs['poses'] if p['instanceId'] in listed])+ self.assertEqual(result['plan']['steps'],full['plan']['steps'])+ self.assertEqual(result['plan']['rootBodyGroup'],full['plan']['rootBodyGroup'])++ def test_moved_scope_matches_the_full_plan_for_every_listed_instance(self):+ cases=[[{'durationSeconds':2,'moves':[self.move(self.f.a,frame='local',distance=10),self.move(frame='parent')]},+ {'durationSeconds':1,'moves':[self.move(distance=4)]}],+ [{'durationSeconds':1,'moves':[self.move(self.f.bp,frame='parent',direction=[0,1,1])]}],+ [{'durationSeconds':1,'moves':[self.move(self.f.b,distance=3)]},{'durationSeconds':1,'moves':[self.move(self.f.ap,frame='local')]}]]+ for steps in cases:+ with self.subTest(steps=steps):+ full=self.read(steps=steps,sampleTimes=[.5,1])+ with patch.object(plan,'MAX_INSTANCES',0):+ scoped=self.read(steps=steps,sampleTimes=[.5,1])+ rows={r['instanceId']:r for r in full['plan']['instances']}+ listed=[r['instanceId'] for r in scoped['plan']['instances']]+ for row in scoped['plan']['instances']:+ self.assertEqual({k:v for k,v in row.items() if k!='label'},{k:v for k,v in rows[row['instanceId']].items() if k!='label'})+ self.assertEqual(listed,[key for key in rows if key in listed]) # the full inventory's order+ for mine,theirs in zip(scoped['samples'],full['samples']):+ self.assertEqual(mine['poses'],[p for p in theirs['poses'] if p['instanceId'] in listed])+ self.assertEqual(scoped['plan']['steps'],full['plan']['steps'])++ def test_moved_scope_reads_parents_by_name_never_their_siblings(self):+ reads={'item':0,'name':0}+ class Level(Collection):+ def item(self,i):reads['item']+=1;return self.values[i]+ def itemByName(self,name):reads['name']+=1;return super().itemByName(name)+ filler=[Entity(objectType='adsk::fusion::Occurrence',name='Bolt:%d'%i,fullPathName='Bolt:%d'%i) for i in range(300)]+ self.f.root.occurrences=Level(filler+self.f.root.occurrences.values)+ self.f.a.childOccurrences=Level(self.f.a.childOccurrences.values)+ self.big()+ result=self.read()+ self.assertEqual([r['path'] for r in result['plan']['instances']],['Assembly:1','Assembly:1+Pin:1'])+ self.assertEqual(reads['item'],0)+ self.assertLessEqual(reads['name'],6) # two levels: resolving the move, then each of the two captures++ def test_a_name_holding_a_plus_is_walked_in_the_moved_scope(self):+ washer=Entity(objectType='adsk::fusion::Occurrence',entityToken='occ-washer',fullPathName='M3+washer:1',name='M3+washer:1',+ component=self.f.pin,assemblyContext=None,nativeObject=None,transform2=NS(asArray=lambda:pose(0,0,5)))+ ring=Entity(objectType='adsk::fusion::Occurrence',entityToken='occ-ring',fullPathName='M3+washer:1+Ring:2',name='Ring:2',+ component=self.f.pin,assemblyContext=washer,nativeObject=None,transform2=NS(asArray=lambda:pose(0,0,6)),childOccurrences=Collection())+ washer.childOccurrences=Collection([ring]);self.f.root.occurrences.values.append(washer)+ self.f.tokens.update({'occ-washer':[washer],'occ-ring':[ring]})+ self.big()+ result=self.read(steps=[{'durationSeconds':1,'moves':[self.move(ring,distance=2)]}])+ self.assertEqual([r['path'] for r in result['plan']['instances']],['M3+washer:1','M3+washer:1+Ring:2'])+ self.assertEqual(self.positions(result)['M3+washer:1+Ring:2'],[2,0,6])++ def test_a_definition_local_reference_is_refused_in_the_moved_scope(self):+ filler=[Entity(objectType='adsk::fusion::Occurrence',name='Bolt:%d'%i,fullPathName='Bolt:%d'%i,nativeObject=None) for i in range(300)]+ self.f.root.allOccurrences=Collection(filler+self.f.root.allOccurrences.values)+ local=self.f.tokens['occ-Pin:1'][0] # the pin's own occurrence, outside the root context+ with self.assertRaises(c.MechanicalError) as error:self.read(steps=[{'durationSeconds':1,'moves':[self.move(local)]}])+ self.assertEqual(error.exception.code,'invalid_context')+ self.assertIn('root-context instance',str(error.exception))++ def test_up_front_budgets_refuse_before_reading_any_instance(self):+ rows=[]+ with patch.object(plan,'instance_row',side_effect=lambda *a:rows.append(a)):+ with patch.object(plan,'MAX_POSES',7),self.assertRaises(c.MechanicalError) as error:self.read() # 4 occurrences x 2 times+ self.assertEqual((error.exception.code,error.exception.details['instances']),('plan_budget',4))+ self.big()+ with patch.object(plan,'MAX_TRACKED',1),self.assertRaises(c.MechanicalError) as error:self.read()+ self.assertEqual((error.exception.code,error.exception.details['tracked']),('plan_budget',2))+ self.assertIn('parent subassembly',error.exception.details['hint'])+ with patch.object(plan,'MAX_POSES',3),self.assertRaises(c.MechanicalError) as error:self.read() # 2 tracked x 2 times+ self.assertEqual((error.exception.details['instances'],error.exception.details['sampleTimes']),(2,2))+ self.assertEqual(rows,[])++ def test_the_time_budget_refuses_with_a_hint_in_both_scopes(self):+ for big in (False,True):+ if big:self.big()+ with patch.object(plan,'BUDGET_SECONDS',-1),self.assertRaises(c.MechanicalError) as error:self.read()+ self.assertEqual((error.exception.code,error.exception.details['instancesRead']),('plan_budget',0))+ self.assertEqual(error.exception.details['instances'],2 if big else 4)+ self.assertIn('Run it again',error.exception.details['hint'])+ self.assertIsInstance(error.exception.details['revisionSeconds'],float) # the revision check before any instance++ def test_part_numbers_are_read_once_per_component_revision_and_a_bom_read_is_reused(self):+ state=importlib.import_module('mechanical_test_commands.mechanical.assembly_state')+ plan._part_numbers.clear();state._native_properties.clear()+ counts={'shared':0,'pin':0}+ def counted(name):+ def read(entity):+ counts[name]+=1;return 'PN-'+name+ return property(read)+ self.f.shared.__class__=type('Shared',(Entity,),{'partNumber':counted('shared')})+ self.f.pin.__class__=type('Pin',(Entity,),{'partNumber':counted('pin')})+ first=self.read();self.read()+ self.assertEqual(counts,{'shared':1,'pin':1}) # four instances of two components, two plans of two captures each+ self.assertEqual({r['partNumber'] for r in first['plan']['instances']},{'PN-shared','PN-pin'})+ self.f.pin.revisionId='r2';self.read()+ self.assertEqual(counts['pin'],2)+ state._native_properties[(c.identity(self.f.pin),'r3')]=('PN-from-bom','')+ self.f.pin.revisionId='r3'+ result=self.read()+ self.assertEqual(counts['pin'],2)+ self.assertIn('PN-from-bom',{r['partNumber'] for r in result['plan']['instances']})++ def test_root_bodies_past_256_are_counted_not_refused(self):+ self.f.root.bRepBodies=Collection([object()]*300)+ self.assertEqual(self.read()['plan']['rootBodyGroup']['bodyCount'],300)+ self.big()+ self.assertEqual(self.read()['plan']['rootBodyGroup']['bodyCount'],300)++ def test_a_component_inserted_by_reference_is_planned_and_a_foreign_one_refused(self):+ self.f.pin.parentDesign=Entity(parentDocument=Entity(dataFile=object()),rootComponent=Entity())+ self.assertEqual(len(self.read()['plan']['instances']),4)+ self.f.pin.parentDesign=Entity(parentDocument=Entity(dataFile=None),rootComponent=Entity())+ with self.assertRaises(c.MechanicalError) as error:self.read()+ self.assertEqual(error.exception.code,'wrong_component') def test_worker_session_and_stale_target_precede_native_snapshot(self): errors=[]@@ -219,4 +351,64 @@ class Exploded(unittest.TestCase): self.assertFalse(result['graphicsCreated']);self.assertFalse(result['storyboardCreated']);self.assertFalse(result['rendered']) +class Node:+ """A root-context occurrence whose native reads are counted."""+ objectType='adsk::fusion::Occurrence'+ reads=0+ def __init__(self,name,component,parent,x):+ self.name,self.component,self.assemblyContext,self.nativeObject,self.isValid=name,component,parent,None,True+ self.childOccurrences=Collection();self.x=x+ self.path=(parent.path+'+' if parent else '')+name+ self.entityToken='occ-'+self.path+ @property+ def fullPathName(self):Node.reads+=1;return self.path+ @property+ def transform2(self):Node.reads+=1;return NS(asArray=lambda:pose(self.x))+++class Scale(unittest.TestCase):+ """The scratch layout measured live: a subassembly of 6 parts and 2 kits of 4 parts (17 occurrences+ with itself) placed many times at the root."""+ def tree(self,copies):+ design=Entity();root=Entity(objectType='adsk::fusion::Component',name='Root',partNumber='',entityToken='root',parentDesign=design,+ bRepBodies=Collection(),revisionId='r1')+ design.rootComponent=root+ comps={name:Entity(objectType='adsk::fusion::Component',name=name,partNumber='',entityToken=name,parentDesign=design,revisionId='r1')+ for name in ('Sub','Kit','Part')}+ tokens={c.entityToken:[c] for c in comps.values()};tokens['root']=[root]+ def add(parent,name,comp,x):+ node=Node(name,comp,parent,x);tokens[node.entityToken]=[node]+ (parent.childOccurrences if parent else root.occurrences).values.append(node)+ return node+ root.occurrences=Collection()+ for s in range(1,copies+1):+ sub=add(None,'Sub:%d'%s,comps['Sub'],s*220)+ for k in range(1,7):add(sub,'Part:%d'%k,comps['Part'],s*220+k*30)+ for kit_index in (1,2):+ kit=add(sub,'Kit:%d'%kit_index,comps['Kit'],s*220)+ for k in range(1,5):add(kit,'Part:%d'%k,comps['Part'],s*220+k*25)+ root.allOccurrences=NS(count=17*copies,item=lambda _:self.fail('Must not read every occurrence'))+ design.findEntityByToken=lambda token:tokens.get(token,[])+ return design+ def plan(self,copies):+ c._entities.clear();design=self.tree(copies);value={'documentId':'doc','revision':'revision'}+ config={'configured':False,'isConfiguration':False,'configurationRowId':'','table':None,'complete':True}+ with patch.object(plan,'target',return_value=(Entity(isSaved=False,isModified=True),design,value)),\+ patch.object(plan,'configuration_snapshot',return_value=config):+ ref=lambda path:c.ref(c.occurrence_at(design,path),value)+ steps=[{'durationSeconds':1,'moves':[{'occurrence':ref('Sub:7+Kit:2+Part:3'),'direction':[0,0,1],'distance':20,'frame':'world'},+ {'occurrence':ref('Sub:7+Kit:1'),'direction':[1,0,0],'distance':30,'frame':'parent'}]},+ {'durationSeconds':1,'moves':[{'occurrence':ref('Sub:9'),'direction':[0,1,0],'distance':50,'frame':'world'}]}]+ Node.reads=0+ result=plan.exploded_view_plan(object(),{'op':'exploded_view_plan','sessionId':plan.SESSION_ID,'target':value,'unit':'mm','steps':steps})+ return result,Node.reads+ def test_the_moved_scope_reads_the_same_whatever_the_design_size(self):+ small,small_reads=self.plan(64) # 1,088 occurrences, as measured live+ big,big_reads=self.plan(270) # 4,590 occurrences+ self.assertEqual([r['path'] for r in big['plan']['instances']],['Sub:7','Sub:7+Kit:1','Sub:7+Kit:2','Sub:7+Kit:2+Part:3','Sub:9'])+ self.assertEqual((small['plan']['designInstances'],big['plan']['designInstances']),(1088,4590))+ self.assertEqual(small_reads,big_reads)+ self.assertLess(big_reads,200)++ if __name__=='__main__':unittest.main()
Reviews
Comments
Log in to comment.
No comments yet.