9090 "1. Scene Setup: pick a lighting preset and background, then Apply." ,
9191 "2. Import: point to FreeCAD or STEPper, add STEP files, click Import." ,
9292 " Each file becomes a collection under MV_Products." ,
93- " Fit on Import scales each one into a Target Size cube at the origin." ,
94- " You can also create empty product collections and drop meshes in," ,
95- " then click Standardize Products to fit them the same way." ,
96- "3. Cameras: pick a view set and front axis, click Build." ,
97- "4. Render: set output folder, mode (full/clay/both), starting index," ,
93+ " You can also create empty product collections and drop meshes in." ,
94+ "3. Standardize: centre and scale every product to one size, and fix" ,
95+ " the faceted shading a CAD tessellation arrives with. Fit on Import" ,
96+ " does the same automatically as each file lands." ,
97+ "4. Cameras: pick a view set and front axis, click Build." ,
98+ " Then use Orient Products to walk the batch one at a time and turn" ,
99+ " anything that faces the wrong way, before rebuilding the cameras." ,
100+ "5. Render: set output folder, mode (full/clay/both), starting index," ,
98101 " then Render All. Files are named PREFIX_NN_Product_View." ,
99102 "" ,
100103 "Render engine, samples, resolution and file format stay under your" ,
@@ -268,6 +271,19 @@ class MV_Settings(PropertyGroup):
268271
269272 show_products_list : BoolProperty (default = False )
270273
274+ active_product_index : IntProperty (
275+ name = "Active Product" ,
276+ description = "Which product the orientation buttons act on" ,
277+ default = 0 , min = 0 ,
278+ )
279+ rotate_step : FloatProperty (
280+ name = "Rotate Step" ,
281+ description = "How far one press of Left/Right/Up/Down turns the product" ,
282+ default = math .radians (90.0 ),
283+ min = math .radians (1.0 ), max = math .radians (180.0 ),
284+ subtype = 'ANGLE' ,
285+ )
286+
271287
272288def ensure_collection (name , parent = None ):
273289 coll = bpy .data .collections .get (name )
@@ -540,6 +556,78 @@ def find_layer_coll(layer_coll, name):
540556 return None
541557
542558
559+ def isolate_product (context , name ):
560+ """Show one product and exclude the rest from the view layer.
561+
562+ Deliberately the same mechanism Render All uses rather than a viewport-only
563+ hide, so stepping through products previews exactly what a batch will
564+ render. Pass name=None to bring everything back.
565+ """
566+ for pc in get_products ():
567+ lc = find_layer_coll (context .view_layer .layer_collection , pc .name )
568+ if lc :
569+ lc .exclude = name is not None and pc .name != name
570+
571+
572+ def active_product (context ):
573+ """The product the orientation buttons act on, or None if there are none.
574+
575+ The stored index is clamped rather than trusted: products get deleted from
576+ the outliner behind the add-on's back, and a stale index would otherwise
577+ raise the moment someone pressed Rotate.
578+ """
579+ products = get_products ()
580+ if not products :
581+ return None , - 1
582+ idx = min (max (context .scene .mv_settings .active_product_index , 0 ), len (products ) - 1 )
583+ return products [idx ], idx
584+
585+
586+ def view_right_axis (context ):
587+ """World-space axis that reads as horizontal in the camera being used.
588+
589+ Up and Down tip the product about whatever is horizontal on screen, so the
590+ buttons behave the same whether you are looking through Front or Top. Falls
591+ back to world Y when no camera exists to ask.
592+ """
593+ cam = context .scene .camera
594+ if cam is None or cam .type != 'CAMERA' :
595+ cam_coll = bpy .data .collections .get (CAMERA_COLL )
596+ cams = [o for o in cam_coll .objects if o .type == 'CAMERA' ] if cam_coll else []
597+ cam = cams [0 ] if cams else None
598+ if cam is None :
599+ return Vector ((0.0 , 1.0 , 0.0 ))
600+ axis = cam .matrix_world .col [0 ].xyz
601+ return axis .normalized () if axis .length > 1e-9 else Vector ((0.0 , 1.0 , 0.0 ))
602+
603+
604+ def rotate_product (context , coll , direction , step ):
605+ """Turn one product in place, as seen from the current view.
606+
607+ Left and Right spin about world Z, which is the turntable move that fixes a
608+ product facing the wrong way. Up and Down tip about the view's horizontal
609+ axis. Rotation happens around the product's own bounding box centre so it
610+ does not drift out of frame, and is applied to the collection roots so a
611+ multi-part assembly turns as one rigid body.
612+ """
613+ objs = collection_meshes (coll )
614+ if not objs :
615+ return False
616+ center , _ = bbox_of (objs )
617+ if direction in {'LEFT' , 'RIGHT' }:
618+ axis = Vector ((0.0 , 0.0 , 1.0 ))
619+ angle = - step if direction == 'LEFT' else step
620+ else :
621+ axis = view_right_axis (context )
622+ angle = - step if direction == 'UP' else step
623+ xform = (Matrix .Translation (center )
624+ @ Matrix .Rotation (angle , 4 , axis )
625+ @ Matrix .Translation (- center ))
626+ for obj in collection_roots (coll ):
627+ obj .matrix_world = xform @ obj .matrix_world
628+ return True
629+
630+
543631def alpha_pair (idx ):
544632 """Repeated-letter group label: AA..ZZ, then AAA..ZZZ, then AAAA..
545633
@@ -1323,6 +1411,74 @@ def execute(self, context):
13231411 return {'FINISHED' }
13241412
13251413
1414+ class MV_OT_RotateProduct (Operator ):
1415+ """Turn the active product 90 degrees so it faces the way the view set expects"""
1416+ bl_idname = "mv.rotate_product"
1417+ bl_label = "Rotate Product"
1418+ bl_options = {'REGISTER' , 'UNDO' }
1419+
1420+ direction : EnumProperty (
1421+ items = [('LEFT' , "Left" , "Spin left about the vertical axis" ),
1422+ ('RIGHT' , "Right" , "Spin right about the vertical axis" ),
1423+ ('UP' , "Up" , "Tip the front face up" ),
1424+ ('DOWN' , "Down" , "Tip the front face down" )],
1425+ default = 'LEFT' ,
1426+ )
1427+
1428+ def execute (self , context ):
1429+ coll , _ = active_product (context )
1430+ if coll is None :
1431+ self .report ({'ERROR' }, f"No product collections under '{ PRODUCT_COLL } '." )
1432+ return {'CANCELLED' }
1433+ step = context .scene .mv_settings .rotate_step
1434+ if not rotate_product (context , coll , self .direction , step ):
1435+ self .report ({'WARNING' }, f"'{ coll .name } ' has no geometry to rotate." )
1436+ return {'CANCELLED' }
1437+ self .report ({'INFO' },
1438+ f"Rotated { coll .name } { self .direction .lower ()} "
1439+ f"{ math .degrees (step ):.0f} degrees." )
1440+ return {'FINISHED' }
1441+
1442+
1443+ class MV_OT_ProductStep (Operator ):
1444+ """Show the next or previous product on its own, hiding the others"""
1445+ bl_idname = "mv.product_step"
1446+ bl_label = "Step Product"
1447+ bl_options = {'REGISTER' , 'UNDO' }
1448+
1449+ delta : IntProperty (default = 1 )
1450+
1451+ def execute (self , context ):
1452+ products = get_products ()
1453+ if not products :
1454+ self .report ({'ERROR' }, f"No product collections under '{ PRODUCT_COLL } '." )
1455+ return {'CANCELLED' }
1456+ s = context .scene .mv_settings
1457+ _ , idx = active_product (context )
1458+ # Wraps, so a review pass round a batch never dead-ends on the last one.
1459+ idx = (idx + self .delta ) % len (products )
1460+ s .active_product_index = idx
1461+ isolate_product (context , products [idx ].name )
1462+ self .report ({'INFO' }, f"{ products [idx ].name } ({ idx + 1 } /{ len (products )} )" )
1463+ return {'FINISHED' }
1464+
1465+
1466+ class MV_OT_ShowAllProducts (Operator ):
1467+ """Bring every product back into the view layer"""
1468+ bl_idname = "mv.show_all_products"
1469+ bl_label = "Show All"
1470+ bl_options = {'REGISTER' , 'UNDO' }
1471+
1472+ def execute (self , context ):
1473+ products = get_products ()
1474+ if not products :
1475+ self .report ({'ERROR' }, f"No product collections under '{ PRODUCT_COLL } '." )
1476+ return {'CANCELLED' }
1477+ isolate_product (context , None )
1478+ self .report ({'INFO' }, f"Showing all { len (products )} product(s)." )
1479+ return {'FINISHED' }
1480+
1481+
13261482class MV_OT_AddProductCollection (Operator ):
13271483 bl_idname = "mv.add_product_collection"
13281484 bl_label = "Add Empty Product Collection"
@@ -1558,22 +1714,35 @@ def draw(self, context):
15581714 for pc in prods :
15591715 box .label (text = f"{ pc .name } ({ len (pc .all_objects )} objs)" , icon = 'OUTLINER_COLLECTION' )
15601716
1717+ class MV_PT_Standardize (Panel ):
1718+ bl_label = "Standardize"
1719+ bl_space_type = 'VIEW_3D'
1720+ bl_region_type = 'UI'
1721+ bl_category = "MultiView"
1722+
1723+ def draw (self , context ):
1724+ layout = self .layout
1725+ s = context .scene .mv_settings
1726+
1727+ layout .prop (s , "fit_on_import" )
1728+ layout .label (text = "Runs as each STEP file lands." , icon = 'INFO' )
1729+
15611730 layout .separator ()
1562- box = layout .box ()
1563- box .label (text = "Standardization:" )
1564- box .prop (s , "fit_on_import" )
1565- row = box .row (align = True )
1731+ row = layout .row (align = True )
15661732 row .prop (s , "recenter" , toggle = True )
15671733 row .prop (s , "rescale" , toggle = True )
1568- sub = box .row ()
1734+ sub = layout .row ()
15691735 sub .enabled = s .rescale or s .fit_on_import
15701736 sub .prop (s , "target_size" )
1571- row = box .row (align = True )
1737+
1738+ row = layout .row (align = True )
15721739 row .prop (s , "auto_smooth" , toggle = True )
15731740 sub = row .row ()
15741741 sub .enabled = s .auto_smooth
15751742 sub .prop (s , "smooth_angle" , text = "" )
1576- box .operator ("mv.standardize" , icon = 'FULLSCREEN_EXIT' )
1743+
1744+ layout .separator ()
1745+ layout .operator ("mv.standardize" , icon = 'FULLSCREEN_EXIT' )
15771746
15781747
15791748class MV_PT_Cameras (Panel ):
@@ -1596,6 +1765,34 @@ def draw(self, context):
15961765
15971766 layout .operator ("mv.build_cameras" , icon = 'CAMERA_DATA' )
15981767
1768+ layout .separator ()
1769+ box = layout .box ()
1770+ box .label (text = "Orient Products:" )
1771+
1772+ products = get_products ()
1773+ if not products :
1774+ box .label (text = "No products to orient." , icon = 'INFO' )
1775+ return
1776+
1777+ coll , idx = active_product (context )
1778+ box .label (text = f"{ coll .name } ({ idx + 1 } /{ len (products )} )" ,
1779+ icon = 'OUTLINER_COLLECTION' )
1780+ box .prop (s , "rotate_step" )
1781+
1782+ col = box .column (align = True )
1783+ row = col .row (align = True )
1784+ row .operator ("mv.rotate_product" , text = "Left" , icon = 'TRIA_LEFT' ).direction = 'LEFT'
1785+ row .operator ("mv.rotate_product" , text = "Right" , icon = 'TRIA_RIGHT' ).direction = 'RIGHT'
1786+ row = col .row (align = True )
1787+ row .operator ("mv.rotate_product" , text = "Up" , icon = 'TRIA_UP' ).direction = 'UP'
1788+ row .operator ("mv.rotate_product" , text = "Down" , icon = 'TRIA_DOWN' ).direction = 'DOWN'
1789+
1790+ row = box .row (align = True )
1791+ row .operator ("mv.product_step" , text = "Previous" , icon = 'FRAME_PREV' ).delta = - 1
1792+ row .operator ("mv.product_step" , text = "Next" , icon = 'FRAME_NEXT' ).delta = 1
1793+ box .operator ("mv.show_all_products" , icon = 'HIDE_OFF' )
1794+ box .label (text = "Rebuild cameras after rotating to reframe." , icon = 'INFO' )
1795+
15991796
16001797class MV_PT_Render (Panel ):
16011798 bl_label = "Batch Render"
@@ -1651,12 +1848,17 @@ def draw(self, context):
16511848 MV_OT_RemoveCustomView ,
16521849 MV_OT_AddProductCollection ,
16531850 MV_OT_Standardize ,
1851+ MV_OT_RotateProduct ,
1852+ MV_OT_ProductStep ,
1853+ MV_OT_ShowAllProducts ,
16541854 MV_OT_RenderAll ,
16551855 MV_UL_StepFiles ,
16561856 MV_UL_CustomViews ,
1857+ # Panels draw in the sidebar in the order they are registered.
16571858 MV_PT_Guide ,
16581859 MV_PT_SceneSetup ,
16591860 MV_PT_Import ,
1861+ MV_PT_Standardize ,
16601862 MV_PT_Cameras ,
16611863 MV_PT_Render ,
16621864)
0 commit comments