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
Drew Owens
The mechanical layer on 1.12.8 with the restart fixes (1.13.0-dev.1, add-in 1.0.23)
2ef0220
15d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
"""Bounded scene observation and detached camera preparation; never renders.
All native access occurs on the main thread. Missing fields remain explicit;
this is not a complete scene witness or evidence of native queue ownership.
"""
import inspect
import json
import math
import threading
from .core import SESSION_ID, MechanicalError, require, target
MAX_ENVIRONMENTS = 32
MAX_OUTPUT_BYTES = 128 * 1024
def scene_observation(app, document, design, *, max_environments=16):
import adsk.core
import adsk.fusion
def need(value, message):
if not value: raise RuntimeError(message)
need(threading.current_thread() is threading.main_thread(), 'Native render reads require the main thread')
need(type(max_environments) is int and 0 <= max_environments <= MAX_ENVIRONMENTS, 'Invalid environment probe budget')
need(document is not None and document.isValid and design is not None and design.isValid, 'Owned native design is invalid')
need(app.activeDocument == document and design.parentDocument == document, 'Current native document/design identity differs')
document_count = app.documents.count
need(type(document_count) is int and 0 <= document_count <= 128, 'Open document inventory exceeds probe budget or has invalid count')
need(sum(app.documents.item(i) == document for i in range(document_count)) == 1, 'Exact owned document is not uniquely open')
result = {'schemaVersion':1, 'status':'OBSERVED', 'renderStarted':False, 'settingsWritten':False,
'sceneWitnessComplete':False, 'renderQualified':False, 'nativeQueueIdle':'UNVERIFIED', 'exclusiveSettingsOwnership':'UNVERIFIED',
'errors':[], 'classes':{}, 'camera':{}, 'rendering':{}, 'scene':{}, 'environments':{},
'gaps':['No render execution or RenderFuture instance was created.',
'No global renderer queue/coexistence proof.',
'Appearance, material, texture, light and occurrence-visibility graphs are outside this bounded probe.',
'Environment IDs and names do not prove custom environment content identity.',
'No admitted revision-to-pixels or full source snapshot claim.']}
def text(value):
need(isinstance(value,str) and len(value) <= 256, 'Native string exceeds probe budget or has wrong type')
return value
def number(value):
need(type(value) in (int,float) and math.isfinite(value), 'Native scalar is not a finite number')
return value
def boolean(value):
need(type(value) is bool, 'Native scalar is not Boolean')
return value
def enum(value):
need(not isinstance(value,(bool,str)), 'Native enum has invalid type')
result = int(value)
need(result == value, 'Native enum is not an exact integer')
return result
def point(value, scale=10):
return [number(number(getattr(value,axis)) * scale) for axis in ('x','y','z')]
def read(path, callback, unit=None, native_unit=None):
try:
data = callback()
row = {'available':True,'value':data}
if unit is not None: row['unit'] = unit
if native_unit is not None: row['nativeUnit'] = native_unit
return row
except Exception as error:
reason = str(error)[:512]
result['errors'].append({'field':path,'error':reason})
return {'available':False,'error':reason}
def skipped(reason): return {'available':None,'notRead':reason}
def object_read(path, callback):
try:
value = callback()
need(value is not None and value.isValid, 'Native object absent or invalid')
text(value.objectType)
return value, {'available':True,'objectType':value.objectType}
except Exception as error:
reason = str(error)[:512]
result['errors'].append({'field':path,'error':reason})
return None, {'available':False,'error':reason}
def fields(destination, prefix, owner, specs):
for name, converter, unit, native_unit in specs:
destination[name] = read(prefix + '.' + name, lambda name=name,converter=converter:converter(getattr(owner,name)), unit, native_unit)
result['documentBefore'] = {'modified':read('document.modified',lambda:boolean(document.isModified)),
'saved':read('document.saved',lambda:boolean(document.isSaved))}
# Static class inspection does not invoke descriptors, constructors, start,
# activation, cancel or any native future polling.
class_members = {'RenderManager':('rendering','sceneSettings','renderEnvironments','lights','isRenderWorkspaceActive'),
'Rendering':('startLocalRender','aspectRatio','resolution','resolutionWidth','resolutionHeight','renderQuality','isBackgroundTransparent'),
'RenderFuture':('filename','imageWidth','imageHeight','progress','renderState','isValid'),
'SceneSettings':('backgroundType','backgroundEnvironment','backgroundSolidColor','isDepthOfFieldEnabled'),
'RenderEnvironment':('id','name','isCustomEnvironment')}
for name,members in class_members.items():
try:
cls = getattr(adsk.fusion,name)
availability = {}
for member in members:
try: inspect.getattr_static(cls,member); availability[member] = True
except AttributeError: availability[member] = False
result['classes'][name] = {'available':True,'members':availability,'methodInvocation':False}
except Exception as error:
result['classes'][name] = {'available':False,'error':str(error)[:512]}
camera_enums = {name:read('CameraTypes.'+name,lambda name=name:enum(getattr(adsk.core.CameraTypes,name)))
for name in ('OrthographicCameraType','PerspectiveCameraType','PerspectiveWithOrthoFacesCameraType')}
background_enums = {name:read('RenderSceneBackgroundTypes.'+name,
lambda name=name:enum(getattr(adsk.fusion.RenderSceneBackgroundTypes,name)))
for name in ('SolidColorRenderSceneBackgroundType','EnvironmentRenderSceneBackgroundType')}
result['enums'] = {'camera':camera_enums,'background':background_enums}
viewport, result['viewport'] = object_read('activeViewport',lambda:app.activeViewport)
camera = None
if viewport is not None:
camera, result['camera']['object'] = object_read('camera',lambda:viewport.camera)
if camera is not None:
fields(result['camera'],'camera',camera,[('cameraType',enum,'native CameraTypes enum',None),
('eye',point,'mm','cm'),('target',point,'mm','cm'),('upVector',lambda p:point(p,1),'unitless',None),
('isFitView',boolean,None,None),('isSmoothTransition',boolean,None,None)])
result['camera']['space'] = 'world/root'
kind = result['camera']['cameraType'].get('value')
if kind is not None and kind == camera_enums['OrthographicCameraType'].get('value'):
def extents():
okay,width,height = camera.getExtents()
need(okay is True, 'Orthographic camera extent read refused')
return [number(number(width)*10),number(number(height)*10)]
result['camera']['extents'] = read('camera.getExtents',extents,'mm','cm')
result['camera']['perspectiveAngle'] = skipped('Orthographic camera')
elif kind is not None and kind in (camera_enums['PerspectiveCameraType'].get('value'),camera_enums['PerspectiveWithOrthoFacesCameraType'].get('value')):
result['camera']['perspectiveAngle'] = read('camera.perspectiveAngle',lambda:number(math.degrees(number(camera.perspectiveAngle))), 'deg', 'rad')
result['camera']['extents'] = skipped('Perspective camera')
else:
result['errors'].append({'field':'camera.cameraType','error':'Unknown/unreadable camera mode; conditional lens getters not read'})
result['camera']['extents'] = result['camera']['perspectiveAngle'] = skipped('Unknown camera mode')
manager, result['renderManager'] = object_read('design.renderManager',lambda:design.renderManager)
if manager is not None:
matched = read('renderManager.parentDesign',lambda:boolean(manager.parentDesign == design))
result['renderManager']['ownerMatches'] = matched
if matched.get('value') is not True:
result['errors'].append({'field':'renderManager.parentDesign','error':'Foreign/unreadable renderer owner; remaining renderer objects not read'})
manager = None
if manager is not None:
result['renderManager']['workspaceActive'] = read('renderManager.isRenderWorkspaceActive',lambda:boolean(manager.isRenderWorkspaceActive))
rendering, result['rendering']['object'] = object_read('renderManager.rendering',lambda:manager.rendering)
if rendering is not None:
fields(result['rendering'],'rendering',rendering,[('aspectRatio',enum,'native RenderAspectRatios enum',None),
('resolution',enum,'native RenderResolutions enum',None),('resolutionWidth',number,'px',None),
('resolutionHeight',number,'px',None),('renderQuality',number,'native quality (25..100)',None),
('isBackgroundTransparent',boolean,None,None)])
scene, result['scene']['object'] = object_read('renderManager.sceneSettings',lambda:manager.sceneSettings)
if scene is not None:
specs = [('aspectRatio',enum,'native RenderAspectRatios enum',None),('aspectRatioWidth',number,'ratio',None),
('aspectRatioHeight',number,'ratio',None),('backgroundType',enum,'native RenderSceneBackgroundTypes enum',None),
('brightness',number,'lux',None),('cameraExposure',number,'EV',None),('cameraFocalLength',number,'mm','mm'),
('cameraType',enum,'native CameraTypes enum',None),('groundOffset',lambda n:number(number(n)*10),'mm','cm'),
('lightAngle',lambda n:number(math.degrees(number(n))),'deg','rad')]
specs += [(name,boolean,None,None) for name in ('isDepthOfFieldEnabled','isGroundDisplayed','isGroundFlattened','isGroundReflections')]
fields(result['scene'],'scene',scene,specs)
for condition,names in (('isDepthOfFieldEnabled',('centerOfFocus','depthOfFieldBlur')),
('isGroundFlattened',('groundPosition',)),('isGroundReflections',('groundRoughness',))):
enabled = result['scene'][condition].get('value')
for name in names:
if enabled is True:
vector = name in ('centerOfFocus','groundPosition')
fields(result['scene'],'scene',scene,[(name,point if vector else number,'mm' if vector else 'unitless','cm' if vector else None)])
else: result['scene'][name] = skipped(condition + ' is false or unreadable')
background = result['scene']['backgroundType'].get('value')
if background is not None and background == background_enums['SolidColorRenderSceneBackgroundType'].get('value'):
def color():
value = scene.backgroundSolidColor
return {name:number(getattr(value,name)) for name in ('red','green','blue','opacity')}
result['scene']['backgroundSolidColor'] = read('scene.backgroundSolidColor',color,'RGBA 0..255; opacity ignored by scene')
result['scene']['backgroundEnvironment'] = skipped('Solid background')
elif background is not None and background == background_enums['EnvironmentRenderSceneBackgroundType'].get('value'):
env, descriptor = object_read('scene.backgroundEnvironment',lambda:scene.backgroundEnvironment)
if env is not None:
fields(descriptor,'scene.backgroundEnvironment',env,[('id',text,None,None),('name',text,None,None),('isCustomEnvironment',boolean,None,None)])
result['scene']['backgroundEnvironment'] = descriptor
result['scene']['backgroundSolidColor'] = skipped('Environment background')
else:
result['errors'].append({'field':'scene.backgroundType','error':'Unknown/unreadable background mode; conditional getters not read'})
result['scene']['backgroundEnvironment'] = result['scene']['backgroundSolidColor'] = skipped('Unknown background mode')
environments, result['environments']['object'] = object_read('renderManager.renderEnvironments',lambda:manager.renderEnvironments)
if environments is not None:
count = read('environments.count',lambda:number(environments.count))
result['environments']['count'] = count
n = count.get('value');result['environments']['items'] = []
if type(n) is int and n >= 0:
result['environments']['truncated'] = n > max_environments
for index in range(min(n,max_environments)):
env, descriptor = object_read('environment.' + str(index),lambda index=index:environments.item(index))
if env is not None:
fields(descriptor,'environment.'+str(index),env,[('id',text,None,None),('name',text,None,None),('isCustomEnvironment',boolean,None,None)])
result['environments']['items'].append(descriptor)
else: result['errors'].append({'field':'environments.count','error':'Environment count is not a nonnegative integer'})
result['documentAfter'] = {'modified':read('document.modified.after',lambda:boolean(document.isModified)),
'saved':read('document.saved.after',lambda:boolean(document.isSaved)),
'stillActive':read('document.active.after',lambda:boolean(app.activeDocument == document))}
result['documentFlagsUnchanged'] = (result['documentBefore']['modified'].get('available') is True
and result['documentBefore']['saved'].get('available') is True
and result['documentBefore']['modified'] == result['documentAfter']['modified']
and result['documentBefore']['saved'] == result['documentAfter']['saved']
and result['documentAfter']['stillActive'].get('value') is True)
classes_complete = all(row.get('available') is True and all(row.get('members',{}).values()) for row in result['classes'].values())
result['observationComplete'] = not result['errors'] and classes_complete and not result['environments'].get('truncated',False)
result['status'] = 'OBSERVED' if result['observationComplete'] else 'PARTIAL'
need(len(json.dumps(result,allow_nan=False).encode()) <= MAX_OUTPUT_BYTES,'Render probe output exceeds byte budget')
return result
def camera_arguments(value):
"""Validate plain input before constructing any native temporary object."""
require(isinstance(value, dict), 'invalid_input', 'camera must be an object.')
common = {'type', 'space', 'unit', 'eye', 'target', 'upVector'}
kind = value.get('type')
lens = {'extents'} if kind == 'orthographic' else {'perspectiveAngle', 'angleUnit'}
require(kind in ('orthographic', 'perspective') and set(value) == common | lens,
'invalid_input', 'Supply one supported camera type with its exact geometry and lens fields.')
require(value['space'] == 'world' and value['unit'] == 'mm', 'invalid_input', 'Camera positions use world-space mm.')
def scalar(x):
require(type(x) in (int, float) and math.isfinite(x) and abs(x) <= 1e9,
'invalid_input', 'Camera geometry must be finite and bounded to 1e9 mm.')
return x
def vector(name):
row = value[name]
require(isinstance(row, list) and len(row) == 3, 'invalid_input', name + ' requires three numbers.')
return [scalar(x) for x in row]
eye, look, up = (vector(name) for name in ('eye', 'target', 'upVector'))
direction = [b-a for a,b in zip(eye,look)]
distance, up_length = math.hypot(*direction), math.hypot(*up)
require(distance > 1e-9, 'invalid_input', 'Camera eye and target must differ.')
require(abs(up_length-1) <= 1e-6 and abs(sum(a*b for a,b in zip(direction,up))/distance) <= 1e-6,
'invalid_input', 'upVector must be a unit vector perpendicular to the view direction.')
if kind == 'orthographic':
extents = value['extents']
require(isinstance(extents,list) and len(extents)==2, 'invalid_input', 'Orthographic extents require width and height in mm.')
require(all(scalar(x) >= 1e-6 for x in extents), 'invalid_input', 'Orthographic extents must be positive.')
else:
require(value['angleUnit']=='deg' and 0 < scalar(value['perspectiveAngle']) < 180,
'invalid_input', 'Perspective angle must be strictly between 0 and 180 degrees.')
return value
def detached_camera(value):
import adsk.core
value = camera_arguments(value)
try:
camera = adsk.core.Camera.create()
require(camera is not None, 'camera_unavailable', 'Camera.create returned no detached camera.')
# Autodesk FUS-263280: transient Camera.isValid can be false even when
# the exact Camera wrapper retains its geometry. Keep that flag as
# evidence; validate type and every requested value independently.
validity_before = camera.isValid
require(type(validity_before) is bool and isinstance(camera, adsk.core.Camera)
and camera.objectType == adsk.core.Camera.classType(),
'camera_unavailable', 'Camera.create returned an unexpected wrapper/type or validity field.')
camera.cameraType = getattr(adsk.core.CameraTypes, 'OrthographicCameraType' if value['type']=='orthographic' else 'PerspectiveCameraType')
camera.eye = adsk.core.Point3D.create(*(x/10 for x in value['eye']))
camera.target = adsk.core.Point3D.create(*(x/10 for x in value['target']))
camera.upVector = adsk.core.Vector3D.create(*value['upVector'])
camera.isFitView = False
camera.isSmoothTransition = False
if value['type'] == 'orthographic':
require(camera.setExtents(*(x/10 for x in value['extents'])) is True,
'camera_readback_mismatch', 'Detached camera extent setter refused.')
else:
camera.perspectiveAngle = math.radians(value['perspectiveAngle'])
def finite_native(number):
require(type(number) in (int,float) and math.isfinite(number),
'camera_readback_mismatch', 'Detached camera returned a nonnumeric or nonfinite value.')
return number
result = {'type':value['type'], 'space':'world', 'unit':'mm',
'eye':[finite_native(getattr(camera.eye,k))*10 for k in ('x','y','z')],
'target':[finite_native(getattr(camera.target,k))*10 for k in ('x','y','z')],
'upVector':[finite_native(getattr(camera.upVector,k)) for k in ('x','y','z')]}
expected_kind = getattr(adsk.core.CameraTypes, 'OrthographicCameraType' if value['type']=='orthographic' else 'PerspectiveCameraType')
require(not isinstance(camera.cameraType,bool) and camera.cameraType == expected_kind and camera.isFitView is False and camera.isSmoothTransition is False,
'camera_readback_mismatch', 'Detached camera mode or behavior differs.')
if value['type'] == 'orthographic':
okay,width,height = camera.getExtents()
require(okay is True, 'camera_readback_mismatch', 'Detached camera extent read refused.')
result['extents'] = [finite_native(width)*10,finite_native(height)*10]
else:
result.update(perspectiveAngle=math.degrees(finite_native(camera.perspectiveAngle)), angleUnit='deg')
# Tolerance must never turn invalid native geometry into a valid camera.
try:
camera_arguments(result)
except MechanicalError as error:
raise MechanicalError('camera_readback_mismatch', 'Detached camera returned invalid geometry or lens: ' + str(error))
def equal(left,right):
if isinstance(right,list):
return isinstance(left,list) and len(left)==len(right) and all(equal(a,b) for a,b in zip(left,right))
if type(right) in (int,float):
return type(left) in (int,float) and math.isfinite(left) and math.isclose(left,right,rel_tol=1e-10,abs_tol=1e-8)
return type(left) is type(right) and left==right
require(all(equal(result.get(k),v) for k,v in value.items()), 'camera_readback_mismatch', 'Detached camera did not retain the requested geometry or lens.')
validity_after = camera.isValid
require(type(validity_after) is bool, 'camera_readback_mismatch', 'Camera validity readback is not Boolean.')
return {'available':True, 'readbackVerified':True, 'assignedToViewport':False,
'nativeValidity':{'before':validity_before, 'after':validity_after,
'knownIssueReference':'FUS-263280', 'renderEligibility':'NOT_ESTABLISHED'},
'scope':'independent temporary camera; current scene is unchanged', 'descriptor':result}
except MechanicalError:
raise
except Exception as error:
raise MechanicalError('camera_unavailable', 'Detached camera construction/readback failed: ' + str(error)[:512])
def render_scene_inspect(app, args):
# This guard must precede target() and every native getter, including ownership.
require(threading.current_thread() is threading.main_thread(), 'wrong_thread', 'Native render reads require the main thread.')
require(args.get('sessionId') == SESSION_ID, 'session_mismatch', 'Scene inspection requires the current add-in session.')
wanted = args.get('target')
require(isinstance(wanted,dict) and isinstance(wanted.get('documentId'),str) and isinstance(wanted.get('revision'),str),
'invalid_input', 'Scene inspection requires exact target documentId and revision.')
budget = args.get('maxEnvironments',16)
require(type(budget) is int and 0 <= budget <= MAX_ENVIRONMENTS, 'invalid_input', 'maxEnvironments must be an integer from 0 to 32.')
if 'camera' in args: camera_arguments(args['camera'])
document, design, value = target(app,args)
first = scene_observation(app,document,design,max_environments=budget)
prepared = detached_camera(args['camera']) if 'camera' in args else {'available':None, 'notRequested':True}
second = scene_observation(app,document,design,max_environments=budget)
_, _, current = target(app,args)
require(value == current and first == second, 'scene_changed_during_inspection',
'The model target or bounded scene observations changed during inspection; no settings were restored.')
require(first['documentFlagsUnchanged'], 'scene_changed_during_inspection',
'Unchanged saved/modified document flags could not be verified.')
first.update(target=value, detachedCamera=prepared, observedStateUnchanged=True,
unchangedScope='available fields only; unreadable fields and omitted scene graphs remain unverified',
observationPasses=2, maxEnvironments=budget)
require(len(json.dumps(first,allow_nan=False).encode('utf-8')) <= MAX_OUTPUT_BYTES,
'output_too_large', 'Scene inspection exceeds its 128 KiB result budget.')
return first
READS = {'render_scene_inspect': render_scene_inspect}