@@ -313,11 +313,23 @@ def ensure_collection(name, parent=None):
313313
314314
315315def get_products_root ():
316+ """The MV_Products collection, created if it is not there yet.
317+
318+ Only for operators. Panels read the products with get_products(), which
319+ never creates anything: draw() runs on every redraw, so reaching for the
320+ root here linked an MV_Products collection into the scene merely because
321+ someone opened the sidebar, and left their file marked as modified for it.
322+ """
316323 return ensure_collection (PRODUCT_COLL )
317324
318325
319326def get_products ():
320- return list (get_products_root ().children )
327+ """Product collections, or [] when the root does not exist yet.
328+
329+ Read-only on purpose -- see get_products_root().
330+ """
331+ root = bpy .data .collections .get (PRODUCT_COLL )
332+ return list (root .children ) if root else []
321333
322334
323335def bbox_of (objs ):
@@ -675,6 +687,38 @@ def product_label(start_prefix, start_number, offset, per_letter):
675687 return f"{ alpha_pair (base + bumps )} _{ num :02d} "
676688
677689
690+ # Characters Windows refuses outright, plus the separators that would quietly
691+ # redirect a write into some other directory.
692+ BAD_PATH_CHARS = '<>:"/\\ |?*'
693+ RESERVED_NAMES = (
694+ {"CON" , "PRN" , "AUX" , "NUL" }
695+ | {f"COM{ i } " for i in range (1 , 10 )}
696+ | {f"LPT{ i } " for i in range (1 , 10 )}
697+ )
698+
699+
700+ def safe_name (name , fallback = "unnamed" ):
701+ """A collection or view name reduced to one usable path component.
702+
703+ These names are free text and usually arrive from a STEP filename, so they
704+ carry whatever the CAD export happened to contain. A trailing space is the
705+ one that bites: asked for 'BICON ', Windows quietly creates 'BICON', then
706+ refuses to write 'BICON \\ shot.png' because that directory really does not
707+ exist, and the render dies on the first product with 'cannot save'. The
708+ mismatch hides well, because os.path.isdir strips the same trailing space
709+ on the way in and cheerfully agrees the folder is there.
710+ """
711+ cleaned = "" .join (
712+ "_" if c in BAD_PATH_CHARS or ord (c ) < 32 else c for c in str (name )
713+ )
714+ # The name we ask for has to match the name the filesystem will hand back,
715+ # or every later write into it misses.
716+ cleaned = cleaned .strip ().rstrip (" ." )
717+ if cleaned .split ("." )[0 ].upper () in RESERVED_NAMES :
718+ cleaned = f"_{ cleaned } "
719+ return cleaned or fallback
720+
721+
678722STEPPER_OPERATORS = (
679723 "import_scene.occ_import_step" ,
680724 "import_scene.occ" ,
@@ -1563,10 +1607,29 @@ def _do_clay_render(scene, filepath, clay_color):
15631607 scene .display .shading .show_cavity = saved ['show_cavity' ]
15641608
15651609
1610+ class _BatchAborted (Exception ):
1611+ """Stop the batch early without dumping a traceback on the user."""
1612+
1613+
15661614class MV_OT_RenderAll (Operator ):
15671615 bl_idname = "mv.render_all"
15681616 bl_label = "Render All"
15691617
1618+ def _fail (self , failures , done , path , exc ):
1619+ """Record one failed write, aborting the batch if nothing has landed.
1620+
1621+ Collected and reported at the end rather than raised, so a single
1622+ unwritable file cannot throw away a long batch that is otherwise fine.
1623+ Before the first success there is nothing to salvage and the cause is
1624+ almost always the setup, so that case stops immediately instead of
1625+ failing the same way for every remaining image.
1626+ """
1627+ note = f"{ os .path .basename (path )} : { exc } "
1628+ failures .append (note )
1629+ print (f"[MultiView] FAILED { note } " )
1630+ if not done :
1631+ raise _BatchAborted from exc
1632+
15701633 def execute (self , context ):
15711634 scene = context .scene
15721635 s = scene .mv_settings
@@ -1583,7 +1646,11 @@ def execute(self, context):
15831646 return {'CANCELLED' }
15841647
15851648 out_root = bpy .path .abspath (s .render_output_dir )
1586- os .makedirs (out_root , exist_ok = True )
1649+ try :
1650+ os .makedirs (out_root , exist_ok = True )
1651+ except OSError as exc :
1652+ self .report ({'ERROR' }, f"Cannot use output folder '{ out_root } ': { exc } " )
1653+ return {'CANCELLED' }
15871654
15881655 saved_hide = {}
15891656 for pc in products :
@@ -1598,6 +1665,7 @@ def execute(self, context):
15981665 total_per_pair = (2 if both else 1 ) * len (cameras )
15991666 total = len (products ) * total_per_pair
16001667 done = 0
1668+ failures = []
16011669
16021670 try :
16031671 for i , pc in enumerate (products ):
@@ -1607,28 +1675,42 @@ def execute(self, context):
16071675 lc .exclude = (other .name != pc .name )
16081676
16091677 label = product_label (s .start_prefix , s .start_number , i , s .products_per_letter )
1610- product_dir = os .path .join (out_root , f"{ label } _{ pc .name } " )
1611- os .makedirs (product_dir , exist_ok = True )
1678+ product_dir = os .path .join (out_root , safe_name (f"{ label } _{ pc .name } " ))
1679+ try :
1680+ os .makedirs (product_dir , exist_ok = True )
1681+ except OSError as exc :
1682+ self ._fail (failures , done , product_dir , exc )
1683+ continue
16121684
16131685 for cam in cameras :
16141686 scene .camera = cam
16151687 view_name = cam .name .replace ("MV_Cam_" , "" )
1616- base = f"{ label } _{ pc .name } _{ view_name } "
1688+ base = safe_name ( f"{ label } _{ pc .name } _{ view_name } " )
16171689
1690+ clay = (tuple (s .clay_color ),)
16181691 if mode == 'FULL' :
1619- _do_full_render (scene , os .path .join (product_dir , base ))
1620- done += 1
1692+ attempts = ((_do_full_render , base , ()),)
16211693 elif mode == 'CLAY' :
1622- _do_clay_render (scene , os .path .join (product_dir , base + "_clay" ),
1623- tuple (s .clay_color ))
1624- done += 1
1694+ attempts = ((_do_clay_render , base + "_clay" , clay ),)
16251695 else :
1626- _do_full_render (scene , os .path .join (product_dir , base + "_render" ))
1696+ attempts = ((_do_full_render , base + "_render" , ()),
1697+ (_do_clay_render , base + "_clay" , clay ))
1698+
1699+ for render , name , extra in attempts :
1700+ path = os .path .join (product_dir , name )
1701+ try :
1702+ render (scene , path , * extra )
1703+ except RuntimeError as exc :
1704+ # bpy.ops.render.render raises when the image cannot
1705+ # be written. Letting that escape put a Python
1706+ # traceback in front of the user instead of the name
1707+ # of the file that failed.
1708+ self ._fail (failures , done , path , exc )
1709+ continue
16271710 done += 1
1628- _do_clay_render (scene , os .path .join (product_dir , base + "_clay" ),
1629- tuple (s .clay_color ))
1630- done += 1
1631- print (f"[MultiView] { done } /{ total } : { scene .render .filepath } " )
1711+ print (f"[MultiView] { done } /{ total } : { scene .render .filepath } " )
1712+ except _BatchAborted :
1713+ pass
16321714 finally :
16331715 for pc in products :
16341716 lc = find_layer_coll (context .view_layer .layer_collection , pc .name )
@@ -1637,7 +1719,19 @@ def execute(self, context):
16371719 scene .camera = saved_cam
16381720 scene .render .filepath = saved_path
16391721
1640- self .report ({'INFO' }, f"Wrote { done } image(s)." )
1722+ if not failures :
1723+ self .report ({'INFO' }, f"Wrote { done } image(s)." )
1724+ return {'FINISHED' }
1725+
1726+ if not done :
1727+ # Nothing landed at all, so the batch stopped at the first failure
1728+ # rather than burning render time failing the same way every time.
1729+ self .report ({'ERROR' }, f"Nothing rendered. { failures [0 ]} " )
1730+ return {'CANCELLED' }
1731+
1732+ self .report ({'WARNING' },
1733+ f"Wrote { done } of { total } image(s). { len (failures )} failed, "
1734+ f"first: { failures [0 ]} . Full list in the System Console." )
16411735 return {'FINISHED' }
16421736
16431737
@@ -1744,6 +1838,7 @@ def draw(self, context):
17441838 for pc in prods :
17451839 box .label (text = f"{ pc .name } ({ len (pc .all_objects )} objs)" , icon = 'OUTLINER_COLLECTION' )
17461840
1841+
17471842class MV_PT_Standardize (Panel ):
17481843 bl_label = "Standardize"
17491844 bl_space_type = 'VIEW_3D'
0 commit comments