|
1 | 1 | bl_info = { |
2 | 2 | "name": "MultiView Product Renderer", |
3 | | - "author": "MultiView", |
| 3 | + "author": "Danyal Sarfraz", |
4 | 4 | "version": (1, 3, 0), |
5 | 5 | "blender": (4, 0, 0), |
6 | 6 | "location": "View3D > Sidebar > MultiView", |
|
84 | 84 | CAMERA_COLL = "MV_Cameras" |
85 | 85 | LIGHT_COLL = "MV_Lighting" |
86 | 86 | PRODUCT_COLL = "MV_Products" |
| 87 | +WORLD_NAME = "MV_World" |
87 | 88 |
|
88 | 89 | GUIDE_LINES = [ |
89 | 90 | "1. Scene Setup: pick a lighting preset and background, then Apply.", |
@@ -207,7 +208,9 @@ class MV_Settings(PropertyGroup): |
207 | 208 |
|
208 | 209 | start_prefix: StringProperty( |
209 | 210 | name="Start Prefix", |
210 | | - description="Two-letter prefix for the first product (AA, BB, ...)", |
| 211 | + description="Letter the first product group is labelled with. The " |
| 212 | + "label repeats it (AA, BB, ...), so only the first " |
| 213 | + "character is read -- typing AB gives AA", |
211 | 214 | default="AA", maxlen=3, |
212 | 215 | ) |
213 | 216 | start_number: IntProperty( |
@@ -321,10 +324,25 @@ def look_at(location, target, up=Vector((0, 0, 1))): |
321 | 324 |
|
322 | 325 |
|
323 | 326 | def clear_collection_objects(coll): |
| 327 | + """Remove a collection's objects along with the data they leave behind. |
| 328 | +
|
| 329 | + objects.remove() drops the object but not its camera or light datablock, so |
| 330 | + every rebuild of the rig orphaned another set and the next one came back as |
| 331 | + MV_Cam_Front.001, .002, ... The file kept growing until a save and reload |
| 332 | + purged them. Data another object still uses, or that is pinned with a fake |
| 333 | + user, is left alone. |
| 334 | + """ |
324 | 335 | if coll is None: |
325 | 336 | return |
326 | 337 | for obj in list(coll.objects): |
| 338 | + data = obj.data |
327 | 339 | bpy.data.objects.remove(obj, do_unlink=True) |
| 340 | + if data is None or data.users or data.use_fake_user: |
| 341 | + continue |
| 342 | + if isinstance(data, bpy.types.Camera): |
| 343 | + bpy.data.cameras.remove(data) |
| 344 | + elif isinstance(data, bpy.types.Light): |
| 345 | + bpy.data.lights.remove(data) |
328 | 346 |
|
329 | 347 |
|
330 | 348 | def collection_meshes(coll): |
@@ -902,9 +920,17 @@ def build_lighting(context, preset_key): |
902 | 920 |
|
903 | 921 |
|
904 | 922 | def apply_world(context): |
| 923 | + """Point the scene at the add-on's own world and rewrite that one. |
| 924 | +
|
| 925 | + This used to reuse whatever world the scene already had and clear its node |
| 926 | + tree, so pressing Apply Background on a file with a hand-built HDRI setup |
| 927 | + destroyed it with no warning and no undo. The add-on now owns a single |
| 928 | + MV_World, the same way it owns MV_Cameras and MV_Lighting. Any other world |
| 929 | + keeps its nodes and stays selectable from World properties. |
| 930 | + """ |
905 | 931 | settings = context.scene.mv_settings |
906 | 932 | scene = context.scene |
907 | | - world = scene.world or bpy.data.worlds.new("MV_World") |
| 933 | + world = bpy.data.worlds.get(WORLD_NAME) or bpy.data.worlds.new(WORLD_NAME) |
908 | 934 | scene.world = world |
909 | 935 | world.use_nodes = True |
910 | 936 | nodes = world.node_tree.nodes |
@@ -998,11 +1024,16 @@ def _fail(self, path, reason): |
998 | 1024 | self._done += 1 |
999 | 1025 |
|
1000 | 1026 | def _link_new(self, context, path, new_objs): |
| 1027 | + # Always a fresh collection. Looking the name up first meant two STEP |
| 1028 | + # files sharing a basename -- partA/housing.step and partB/housing.step |
| 1029 | + # -- landed in one collection and rendered as a single product, and an |
| 1030 | + # unrelated collection that happened to be called "housing" would have |
| 1031 | + # the geometry dropped into it. Blender resolves the clash itself by |
| 1032 | + # suffixing .001, which keeps the two products separate. |
1001 | 1033 | root = get_products_root() |
1002 | 1034 | base = os.path.splitext(os.path.basename(path))[0] |
1003 | | - product_coll = bpy.data.collections.get(base) or bpy.data.collections.new(base) |
1004 | | - if product_coll.name not in root.children: |
1005 | | - root.children.link(product_coll) |
| 1035 | + product_coll = bpy.data.collections.new(base) |
| 1036 | + root.children.link(product_coll) |
1006 | 1037 | for obj in new_objs: |
1007 | 1038 | for c in list(obj.users_collection): |
1008 | 1039 | c.objects.unlink(obj) |
@@ -1071,7 +1102,19 @@ def _advance(self, context, blocking): |
1071 | 1102 | """One slice of work. False once everything is finished.""" |
1072 | 1103 | if self._job is not None: |
1073 | 1104 | if blocking: |
1074 | | - self._job["proc"].wait() |
| 1105 | + # Bounded even here. A bare wait() meant a wedged FreeCAD hung |
| 1106 | + # a scripted or headless run forever, with only the modal path |
| 1107 | + # honouring the timeout. Same deadline either way, measured |
| 1108 | + # from when the process started. |
| 1109 | + remaining = FREECAD_TIMEOUT - (time.monotonic() - self._job_started) |
| 1110 | + try: |
| 1111 | + self._job["proc"].wait(timeout=max(remaining, 0.0)) |
| 1112 | + except subprocess.TimeoutExpired: |
| 1113 | + job, self._job = self._job, None |
| 1114 | + freecad_cleanup(job) |
| 1115 | + self._fail(self._job_path, |
| 1116 | + f"FreeCAD exceeded {int(FREECAD_TIMEOUT)}s and was stopped") |
| 1117 | + return bool(self._queue) |
1075 | 1118 | elif freecad_running(self._job): |
1076 | 1119 | if time.monotonic() - self._job_started > FREECAD_TIMEOUT: |
1077 | 1120 | job, self._job = self._job, None |
@@ -1289,10 +1332,13 @@ def invoke(self, context, event): |
1289 | 1332 | return context.window_manager.invoke_props_dialog(self) |
1290 | 1333 |
|
1291 | 1334 | def execute(self, context): |
| 1335 | + # Create unconditionally and report the name that came back. Skipping |
| 1336 | + # when the name was taken made the button look broken on the second |
| 1337 | + # press -- no collection, no message, nothing in the outliner. |
1292 | 1338 | root = get_products_root() |
1293 | | - n = self.name or "Product" |
1294 | | - if bpy.data.collections.get(n) is None: |
1295 | | - root.children.link(bpy.data.collections.new(n)) |
| 1339 | + coll = bpy.data.collections.new(self.name or "Product") |
| 1340 | + root.children.link(coll) |
| 1341 | + self.report({'INFO'}, f"Added product collection '{coll.name}'.") |
1296 | 1342 | return {'FINISHED'} |
1297 | 1343 |
|
1298 | 1344 |
|
|
0 commit comments