extapps 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1146 @@
1
+ # !/usr/bin/python
2
+ # coding=utf-8
3
+ """Map Converter UI — slot file for ``map_converter.ui``.
4
+
5
+ Bundles texture-map conversion, channel packing, PBR-workflow prep, and bulk
6
+ optimization into a single Switchboard panel. The heavy lifting lives in
7
+ ``MapFactory`` / ``ImgUtils`` (in pythontk) — this module is the UI wiring only.
8
+
9
+ This module exposes :class:`MapConverterSlots` — the Switchboard slot class.
10
+ Method names map to widget ``objectName`` in the .ui file: ``tb*`` =
11
+ toolbutton (has an options menu populated by the matching ``*_init`` hook),
12
+ ``b*`` = plain button. Host integrations can inject a ``texture_provider``
13
+ callable to read the DCC selection.
14
+
15
+ The standalone launcher :class:`MapConverterUI` lives in the sibling
16
+ :mod:`extapps.map_converter.launcher` module.
17
+ """
18
+ import os
19
+ import tempfile
20
+ from typing import List, Union, Tuple, Dict, Any
21
+
22
+ from qtpy.QtWidgets import QPushButton
23
+
24
+ # From this package:
25
+ from pythontk.img_utils._img_utils import ImgUtils
26
+ from pythontk.img_utils.map_factory import MapFactory
27
+ from pythontk.img_utils.map_registry import MapRegistry
28
+ from pythontk.file_utils._file_utils import FileUtils
29
+
30
+
31
+ class MapConverterSlots(ImgUtils):
32
+ """Switchboard slots for ``map_converter.ui``.
33
+
34
+ Slot methods are bound to widgets by name. The ``Use Selection`` footer
35
+ toggle (installed by :meth:`footer_init`) routes every tool through
36
+ :attr:`texture_provider` when set; otherwise tools fall back to a file
37
+ dialog. Set :attr:`source_dir` to seed the initial dialog directory.
38
+ """
39
+
40
+ def __init__(self, switchboard, **kwargs):
41
+ super().__init__()
42
+
43
+ self.sb = switchboard
44
+ self.ui = self.sb.loaded_ui.map_converter
45
+
46
+ self._source_dir = kwargs.get("source_dir", "")
47
+ self._texture_provider = kwargs.get("texture_provider", None)
48
+
49
+ @property
50
+ def source_dir(self):
51
+ """Get the starting directory for file dialogs."""
52
+ return self._source_dir
53
+
54
+ @source_dir.setter
55
+ def source_dir(self, value):
56
+ """Set the starting directory for file dialogs."""
57
+ self._source_dir = value
58
+
59
+ @property
60
+ def texture_provider(self):
61
+ """Callable returning a list of texture paths from the host DCC selection.
62
+
63
+ Set by the host integration to power the global "Use Selection"
64
+ footer toggle. Returns None when running standalone — every tool
65
+ then falls back to the file dialog regardless of the toggle's
66
+ state.
67
+ """
68
+ return self._texture_provider
69
+
70
+ @texture_provider.setter
71
+ def texture_provider(self, fn):
72
+ self._texture_provider = fn
73
+
74
+ def footer_init(self, widget):
75
+ """Add the global Use-Selection toggle to the footer."""
76
+ self.btn_use_selection = QPushButton("Use Selection")
77
+ self.btn_use_selection.setObjectName("btn_use_selection")
78
+ self.btn_use_selection.setCheckable(True)
79
+ self.btn_use_selection.setChecked(False)
80
+ self.btn_use_selection.setToolTip(
81
+ "When enabled, every tool reads texture paths from the host "
82
+ "DCC's current selection instead of opening a file browser."
83
+ )
84
+ widget.add_widget(self.btn_use_selection, side="right", background=True)
85
+
86
+ def _selection_enabled(self):
87
+ """True when the footer's Use-Selection toggle is on."""
88
+ try:
89
+ return self.btn_use_selection.isChecked()
90
+ except (AttributeError, RuntimeError):
91
+ return False
92
+
93
+ def _get_texture_paths(self, *, title, map_type_filter=None, allow_multiple=True):
94
+ """Resolve texture paths via the global Use-Selection toggle.
95
+
96
+ Parameters:
97
+ title (str): Title shown if the file dialog is used.
98
+ map_type_filter (Iterable[str], optional): When pulling from
99
+ selection, restrict to these MapRegistry keys (e.g. ``["Normal",
100
+ "Normal_DirectX"]``). Ignored when the file dialog is used.
101
+ allow_multiple (bool): Forwarded to ``file_dialog``.
102
+
103
+ Returns:
104
+ List[str]: Existing absolute paths. Empty list when nothing valid.
105
+ """
106
+ use_selected = self._selection_enabled()
107
+
108
+ if use_selected and self.texture_provider:
109
+ paths = list(self.texture_provider() or [])
110
+ if map_type_filter:
111
+ wanted = set(map_type_filter)
112
+ kept, dropped = [], []
113
+ for p in paths:
114
+ key = MapFactory.resolve_map_type(p, key=True)
115
+ (kept if key in wanted else dropped).append(p)
116
+ if dropped:
117
+ print(
118
+ f"// Skipping {len(dropped)} map(s) not in "
119
+ f"{sorted(wanted)} from selection."
120
+ )
121
+ paths = kept
122
+ if not paths:
123
+ print("// No matching textures found on the selected objects.")
124
+ return []
125
+ else:
126
+ if use_selected:
127
+ print(
128
+ "// 'Use Selection' is enabled but no texture provider is "
129
+ "wired up — falling back to file dialog."
130
+ )
131
+ paths = self.sb.file_dialog(
132
+ file_types=[f"*.{ext}" for ext in self.texture_file_types],
133
+ title=title,
134
+ start_dir=self.source_dir,
135
+ allow_multiple=allow_multiple,
136
+ )
137
+ paths = list(paths or [])
138
+
139
+ valid = [p for p in paths if p and os.path.isfile(p)]
140
+ for missing in (p for p in paths if p not in valid):
141
+ print(f"// Skipping (file not found): {missing}")
142
+ return valid
143
+
144
+ def tb000_init(self, widget):
145
+ """Populate the Optimize toolbutton's option menu (format, clamp, modifier)."""
146
+ widget.option_box.menu.setTitle("Optimize")
147
+ widget.option_box.menu.add(
148
+ "QComboBox",
149
+ setObjectName="cmb001",
150
+ setToolTip="Set the output file type. 'Original' keeps each texture's existing format.",
151
+ )
152
+ # Falsy sentinels (empty string / 0) — the prefix-mode combobox
153
+ # replaces explicit None data with the label string, so use values
154
+ # that still evaluate falsy in the ``if not file_type`` / ``if not
155
+ # max_size`` checks below.
156
+ widget.option_box.menu.cmb001.add(
157
+ [("Original", "")] + [(ext.upper(), ext) for ext in self.texture_file_types],
158
+ prefix="Format:",
159
+ )
160
+
161
+ widget.option_box.menu.add(
162
+ "QComboBox",
163
+ setObjectName="cmb000",
164
+ setToolTip="Maximum dimension (longest side). 'None' disables resizing.",
165
+ )
166
+ widget.option_box.menu.cmb000.add(
167
+ [("None", 0)] + [(s, int(s)) for s in ("256", "512", "1024", "2048", "4096", "8192")],
168
+ prefix="Clamp:",
169
+ )
170
+
171
+ widget.option_box.menu.add(
172
+ "QComboBox",
173
+ setObjectName="cmb_secondary_scale",
174
+ setToolTip=(
175
+ "Downscale non-critical maps (roughness, metallic, AO, masks, "
176
+ "height, etc.) by this fraction of the clamp. Resolution-critical "
177
+ "maps (base color, normals, emissive) always use the full clamp."
178
+ ),
179
+ )
180
+ widget.option_box.menu.cmb_secondary_scale.add(
181
+ [("Full", 1.0), ("1/2", 0.5), ("1/4", 0.25), ("1/8", 0.125)],
182
+ prefix="Secondary:",
183
+ )
184
+
185
+ widget.option_box.menu.add(
186
+ "QComboBox",
187
+ setObjectName="cmb_mode",
188
+ setToolTip="Apply the modifier as a Suffix (after base name) or Prefix (before base name). Either way it sits before the map-type suffix.",
189
+ )
190
+ widget.option_box.menu.cmb_mode.addItems(["Suffix", "Prefix"])
191
+
192
+ widget.option_box.menu.add(
193
+ "QLineEdit",
194
+ setObjectName="txt_modifier",
195
+ setPlaceholderText="e.g. LD",
196
+ setToolTip=(
197
+ "Text inserted into the base name (before the map-type suffix). "
198
+ "Empty = overwrite the original file."
199
+ ),
200
+ )
201
+
202
+ widget.option_box.menu.add(
203
+ "QLineEdit",
204
+ setObjectName="txt_old_folder",
205
+ setText="old",
206
+ setPlaceholderText="e.g. old",
207
+ setToolTip=(
208
+ "Subdirectory under the texture's folder to move the original into. "
209
+ "Empty = don't move the original."
210
+ ),
211
+ )
212
+
213
+ def tb000(self, widget):
214
+ """Optimize a texture map(s)"""
215
+ texture_paths = self._get_texture_paths(
216
+ title="Select texture map(s) to optimize:"
217
+ )
218
+ if not texture_paths:
219
+ return
220
+
221
+ # Falsy sentinels ("", 0) → None so optimize_texture preserves
222
+ # the original format / skips clamping respectively.
223
+ file_type = widget.option_box.menu.cmb001.currentData() or None
224
+ max_size = widget.option_box.menu.cmb000.currentData() or None
225
+ secondary_scale = (
226
+ widget.option_box.menu.cmb_secondary_scale.currentData() or 1.0
227
+ )
228
+ mode = widget.option_box.menu.cmb_mode.currentText().lower()
229
+ modifier = widget.option_box.menu.txt_modifier.text().strip().strip("_")
230
+ old_folder = (
231
+ widget.option_box.menu.txt_old_folder.text().strip().strip("/").strip("\\")
232
+ )
233
+
234
+ registry = MapRegistry()
235
+
236
+ for texture_path in texture_paths:
237
+ print(f"Optimizing: {texture_path} ..")
238
+
239
+ # Apply the secondary scale to non-critical maps so masks/roughness/
240
+ # etc. shrink relative to base color and normals.
241
+ effective_max_size = max_size
242
+ if max_size and secondary_scale != 1.0:
243
+ map_type_key = MapFactory.resolve_map_type(texture_path, key=True)
244
+ if not registry.is_resolution_critical(map_type_key):
245
+ effective_max_size = max(1, int(max_size * secondary_scale))
246
+ print(
247
+ f"// Secondary scale {secondary_scale:g}x → clamp "
248
+ f"{effective_max_size} ({map_type_key or 'unknown type'})"
249
+ )
250
+
251
+ if not modifier:
252
+ # Overwrite mode: optimize in place. optimize_texture handles
253
+ # the optional move-to-old-folder for us.
254
+ optimized_map_path = self.optimize_texture(
255
+ texture_path,
256
+ output_type=file_type,
257
+ max_size=effective_max_size,
258
+ old_files_folder=old_folder or None,
259
+ optimize_bit_depth=True,
260
+ )
261
+ else:
262
+ # Rename mode: place the modifier between base name and
263
+ # map-type suffix and save alongside the original.
264
+ directory = FileUtils.format_path(texture_path, "path")
265
+ base_name = self.get_base_texture_name(texture_path)
266
+ map_type = MapFactory.resolve_map_type(texture_path, key=False) or ""
267
+ out_ext = (
268
+ (file_type or FileUtils.format_path(texture_path, "ext"))
269
+ .lower()
270
+ .lstrip(".")
271
+ )
272
+ new_base = (
273
+ f"{modifier}_{base_name}"
274
+ if mode == "prefix"
275
+ else f"{base_name}_{modifier}"
276
+ )
277
+ out_filename = (
278
+ f"{new_base}_{map_type}.{out_ext}"
279
+ if map_type
280
+ else f"{new_base}.{out_ext}"
281
+ )
282
+ target_path = os.path.join(directory, out_filename)
283
+
284
+ # Same-drive temp dir so the final os.replace is a fast rename
285
+ # and overwrites cleanly on re-run. We can't pass
286
+ # old_files_folder here because optimize_texture would archive
287
+ # the original into the temp dir and lose it on cleanup.
288
+ with tempfile.TemporaryDirectory(dir=directory) as temp_dir:
289
+ temp_result = self.optimize_texture(
290
+ texture_path,
291
+ output_dir=temp_dir,
292
+ output_type=file_type,
293
+ max_size=effective_max_size,
294
+ optimize_bit_depth=True,
295
+ )
296
+ os.replace(temp_result, target_path)
297
+
298
+ if old_folder:
299
+ FileUtils.move_file(
300
+ texture_path, os.path.join(directory, old_folder)
301
+ )
302
+
303
+ optimized_map_path = target_path
304
+
305
+ print(f"// Result: {optimized_map_path}")
306
+ self.source_dir = FileUtils.format_path(texture_paths[0], "path")
307
+
308
+ def tb001_init(self, widget):
309
+ """ """
310
+ widget.option_box.menu.setTitle("Spec Gloss to PBR")
311
+ widget.option_box.menu.add(
312
+ "QCheckBox",
313
+ setText="Create MetallicSmoothness map",
314
+ setObjectName="chk000",
315
+ setToolTip="Also create a MetallicSmoothness map.",
316
+ )
317
+
318
+ def tb001(self, widget):
319
+ """Batch converts Spec/Gloss maps to PBR Metal/Rough using MapFactory.
320
+
321
+ User selects multiple texture sets. The function groups them per base name
322
+ and converts them accordingly using the DRY MapFactory.
323
+
324
+ Maps are saved as Metallic/Roughness maps in the same directory.
325
+ """
326
+ spec_map_paths = self._get_texture_paths(
327
+ title="Select Specular, Gloss (optional), and Diffuse maps to convert:",
328
+ )
329
+ if not spec_map_paths:
330
+ return
331
+
332
+ create_metallic_smoothness = widget.option_box.menu.chk000.isChecked()
333
+
334
+ # Use MapFactory for DRY conversion
335
+ workflow_config = {
336
+ "albedo_transparency": False,
337
+ "metallic_smoothness": create_metallic_smoothness,
338
+ "mask_map": False,
339
+ "normal_type": "OpenGL",
340
+ "output_extension": "png",
341
+ "convert_specgloss_to_pbr": True,
342
+ }
343
+
344
+ print(f"Processing {len(spec_map_paths)} files...")
345
+
346
+ try:
347
+ results = MapFactory.prepare_maps(
348
+ spec_map_paths,
349
+ callback=print,
350
+ **workflow_config,
351
+ )
352
+
353
+ if isinstance(results, dict):
354
+ print(f"Processed {len(results)} texture sets.")
355
+ for base_name, maps in results.items():
356
+ print(f"Set: {base_name}")
357
+ for m in maps:
358
+ print(f" - {m}")
359
+ else:
360
+ print("Processed single set.")
361
+ for m in results:
362
+ print(f" - {m}")
363
+
364
+ except Exception as e:
365
+ print(f"Error during batch processing: {e}")
366
+ import traceback
367
+
368
+ traceback.print_exc()
369
+
370
+ try:
371
+ self.source_dir = FileUtils.format_path(spec_map_paths[0], "path")
372
+ except Exception:
373
+ pass
374
+
375
+ def tb003_init(self, widget):
376
+ """Initialize a 'Bump to Normal' toolbutton with options."""
377
+ widget.option_box.menu.setTitle("Bump to Normal")
378
+ widget.option_box.menu.add(
379
+ "QComboBox",
380
+ setObjectName="tb003_cmb_format",
381
+ setToolTip="OpenGL: Y+ up, DirectX: Y+ down",
382
+ )
383
+ # Display-friendly items with data values
384
+ cmb = widget.option_box.menu.tb003_cmb_format
385
+ cmb.clear()
386
+ cmb.addItem("Format: OpenGL", "opengl")
387
+ cmb.addItem("Format: DirectX", "directx")
388
+
389
+ widget.option_box.menu.add(
390
+ "QDoubleSpinBox",
391
+ setObjectName="tb003_dsb_intensity",
392
+ setMinimum=0.1,
393
+ setMaximum=5.0,
394
+ setSingleStep=0.1,
395
+ setValue=1.0,
396
+ setDecimals=2,
397
+ setPrefix="Intensity: ",
398
+ setToolTip="Controls how deep the height values are interpreted",
399
+ )
400
+
401
+ def tb003(self, widget):
402
+ """Bump/Height to Normal converter (single entry point with options)."""
403
+ bump_map_paths = self._get_texture_paths(
404
+ title="Select bump/height maps to convert:",
405
+ map_type_filter=["Bump", "Height"],
406
+ )
407
+ if not bump_map_paths:
408
+ return
409
+
410
+ # Options
411
+ try:
412
+ output_format = (
413
+ widget.option_box.menu.tb003_cmb_format.currentData() or "opengl"
414
+ )
415
+ except Exception:
416
+ fmt_text = widget.option_box.menu.tb003_cmb_format.currentText().lower()
417
+ output_format = "directx" if "directx" in fmt_text else "opengl"
418
+ intensity = widget.option_box.menu.tb003_dsb_intensity.value()
419
+
420
+ for bump_path in bump_map_paths:
421
+ print(f"Converting bump to normal ({output_format.upper()}): {bump_path}")
422
+
423
+ try:
424
+ normal_path = MapFactory.convert_bump_to_normal(
425
+ bump_path,
426
+ output_format=output_format,
427
+ intensity=intensity,
428
+ smooth_filter=True,
429
+ filter_radius=0.5,
430
+ )
431
+ print(f"// Result: {normal_path}")
432
+
433
+ except Exception as e:
434
+ print(f"// Error converting {bump_path}: {e}")
435
+
436
+ try:
437
+ self.source_dir = FileUtils.format_path(bump_map_paths[0], "path")
438
+ except Exception:
439
+ pass
440
+
441
+ def b000(self):
442
+ """Convert DirectX to OpenGL"""
443
+ dx_map_paths = self._get_texture_paths(
444
+ title="Select a DirectX normal map to convert:",
445
+ map_type_filter=["Normal", "Normal_DirectX"],
446
+ )
447
+ if not dx_map_paths:
448
+ return
449
+
450
+ for dx_map_path in dx_map_paths:
451
+ print(f"Converting: {dx_map_path} ..")
452
+ gl_map_path = MapFactory.convert_normal_map_format(
453
+ dx_map_path, target_format="opengl"
454
+ )
455
+ print(f"// Result: {gl_map_path}")
456
+ self.source_dir = FileUtils.format_path(dx_map_paths[0], "path")
457
+
458
+ def b001(self):
459
+ """Convert OpenGL to DirectX"""
460
+ gl_map_paths = self._get_texture_paths(
461
+ title="Select an OpenGL normal map to convert:",
462
+ map_type_filter=["Normal", "Normal_OpenGL"],
463
+ )
464
+ if not gl_map_paths:
465
+ return
466
+
467
+ for gl_map_path in gl_map_paths:
468
+ print(f"Converting: {gl_map_path} ..")
469
+ dx_map_path = MapFactory.convert_normal_map_format(
470
+ gl_map_path, target_format="directx"
471
+ )
472
+ print(f"// Result: {dx_map_path}")
473
+ self.source_dir = FileUtils.format_path(gl_map_paths[0], "path")
474
+
475
+ def b004(self):
476
+ """Batch pack Transparency into Albedo across texture sets."""
477
+ paths = self._get_texture_paths(
478
+ title="Select one or more sets of Albedo/Base Color and Transparency maps:",
479
+ )
480
+ if not paths:
481
+ return
482
+
483
+ texture_sets = MapFactory.group_textures_by_set(paths)
484
+
485
+ for base_name, files in texture_sets.items():
486
+ sorted_maps = MapFactory.sort_images_by_type(files)
487
+
488
+ albedo_map_path = sorted_maps.get("Albedo_Transparency", [None])[0]
489
+ base_color_path = sorted_maps.get("Base_Color", [None])[0]
490
+ opacity_map_path = sorted_maps.get("Opacity", [None])[0]
491
+
492
+ if not (albedo_map_path or base_color_path):
493
+ print(f"Skipping {base_name}: No Albedo or Base Color map found.")
494
+ continue
495
+
496
+ if not opacity_map_path:
497
+ print(f"Skipping {base_name}: No Transparency (Opacity) map found.")
498
+ continue
499
+
500
+ rgb_map_path = albedo_map_path or base_color_path
501
+
502
+ print(
503
+ f"Packing Transparency from: {opacity_map_path}\n\tinto: {rgb_map_path} .."
504
+ )
505
+
506
+ packed_path = MapFactory.pack_transparency_into_albedo(
507
+ rgb_map_path,
508
+ opacity_map_path,
509
+ invert_alpha=False,
510
+ )
511
+ print(f"// Result: {packed_path}")
512
+
513
+ try:
514
+ self.source_dir = FileUtils.format_path(paths[0], "path")
515
+ except Exception:
516
+ pass
517
+
518
+ def b005(self):
519
+ """Batch pack Smoothness or Roughness into Metallic across texture sets."""
520
+ paths = self._get_texture_paths(
521
+ title="Select one or more sets of metallic and smoothness/roughness maps:",
522
+ )
523
+ if not paths:
524
+ return
525
+
526
+ texture_sets = MapFactory.group_textures_by_set(paths)
527
+
528
+ for base_name, files in texture_sets.items():
529
+ sorted_maps = MapFactory.sort_images_by_type(files)
530
+
531
+ metallic_map_path = sorted_maps.get("Metallic", [None])[0]
532
+ smooth_map_path = sorted_maps.get("Smoothness", [None])[0]
533
+ rough_map_path = sorted_maps.get("Roughness", [None])[0]
534
+
535
+ if not metallic_map_path:
536
+ print(f"Skipping {base_name}: No Metallic map found.")
537
+ continue
538
+
539
+ alpha_map_path = smooth_map_path or rough_map_path
540
+ invert_alpha = rough_map_path is not None
541
+
542
+ if not alpha_map_path:
543
+ print(f"Skipping {base_name}: No Smoothness or Roughness map found.")
544
+ continue
545
+
546
+ print(
547
+ f"Packing {'Roughness' if invert_alpha else 'Smoothness'} from: {alpha_map_path}\n\tinto: {metallic_map_path} .."
548
+ )
549
+
550
+ packed_path = MapFactory.pack_smoothness_into_metallic(
551
+ metallic_map_path,
552
+ alpha_map_path,
553
+ invert_alpha=invert_alpha,
554
+ )
555
+ print(f"// Result: {packed_path}")
556
+
557
+ try:
558
+ self.source_dir = FileUtils.format_path(paths[0], "path")
559
+ except Exception:
560
+ pass
561
+
562
+ def b006(self):
563
+ """Unpack Metallic and Smoothness maps from MetallicSmoothness textures."""
564
+ print("Unpacking Metallic and Smoothness maps ..")
565
+ metallic_smoothness_paths = self._get_texture_paths(
566
+ title="Select MetallicSmoothness maps to unpack:",
567
+ map_type_filter=["Metallic_Smoothness"],
568
+ )
569
+ if not metallic_smoothness_paths:
570
+ return
571
+
572
+ for metallic_smoothness_path in metallic_smoothness_paths:
573
+ print(f"Unpacking: {metallic_smoothness_path} ..")
574
+
575
+ try:
576
+ metallic_path, smoothness_path = MapFactory.unpack_metallic_smoothness(
577
+ metallic_smoothness_path
578
+ )
579
+ print(f"// Metallic map: {metallic_path}")
580
+ print(f"// Smoothness map: {smoothness_path}")
581
+
582
+ except Exception as e:
583
+ print(f"// Error unpacking {metallic_smoothness_path}: {e}")
584
+
585
+ try:
586
+ self.source_dir = FileUtils.format_path(
587
+ metallic_smoothness_paths[0], "path"
588
+ )
589
+ except Exception:
590
+ pass
591
+
592
+ def b007(self):
593
+ """Unpack Specular and Gloss maps from SpecularGloss textures."""
594
+ specular_gloss_paths = self._get_texture_paths(
595
+ title="Select SpecularGloss maps to unpack:",
596
+ map_type_filter=["Specular"],
597
+ )
598
+ if not specular_gloss_paths:
599
+ return
600
+
601
+ for specular_gloss_path in specular_gloss_paths:
602
+ print(f"Unpacking: {specular_gloss_path} ..")
603
+
604
+ try:
605
+ specular_path, gloss_path = MapFactory.unpack_specular_gloss(
606
+ specular_gloss_path
607
+ )
608
+ print(f"// Specular map: {specular_path}")
609
+ print(f"// Gloss map: {gloss_path}")
610
+
611
+ except Exception as e:
612
+ print(f"// Error unpacking {specular_gloss_path}: {e}")
613
+
614
+ try:
615
+ self.source_dir = FileUtils.format_path(specular_gloss_paths[0], "path")
616
+ except Exception:
617
+ pass
618
+
619
+ def b008_init(self, widget):
620
+ """Populate the MSAO pack toolbutton's option menu (channel layout)."""
621
+ widget.option_box.menu.setTitle("Pack MSAO")
622
+ widget.option_box.menu.add(
623
+ "QComboBox",
624
+ setObjectName="cmb_msao_layout",
625
+ setToolTip=(
626
+ "RGBA: HDRP Mask Map (R=Metallic, G=AO, B=Detail, A=Smoothness).\n"
627
+ "RGB: 3-channel parallel layout (R=Metallic, G=Smoothness, B=AO)."
628
+ ),
629
+ )
630
+ widget.option_box.menu.cmb_msao_layout.add(
631
+ [("RGBA (HDRP Mask Map)", "rgba"), ("RGB (3-channel)", "rgb")],
632
+ prefix="Layout:",
633
+ )
634
+
635
+ def b008(self, widget):
636
+ """Batch pack Metallic, AO, and Smoothness/Roughness into an MSAO texture."""
637
+ paths = self._get_texture_paths(
638
+ title="Select one or more sets of Metallic, Ambient Occlusion, and Smoothness maps:",
639
+ )
640
+ if not paths:
641
+ return
642
+
643
+ layout = (
644
+ widget.option_box.menu.cmb_msao_layout.currentData() or "rgba"
645
+ )
646
+
647
+ texture_sets = MapFactory.group_textures_by_set(paths)
648
+
649
+ for base_name, files in texture_sets.items():
650
+ sorted_maps = MapFactory.sort_images_by_type(files)
651
+
652
+ metallic_map_path = sorted_maps.get("Metallic", [None])[0]
653
+ ao_map_path = sorted_maps.get("Ambient_Occlusion", [None])[0]
654
+ smoothness_map_path = sorted_maps.get("Smoothness", [None])[0]
655
+ roughness_map_path = sorted_maps.get("Roughness", [None])[0]
656
+
657
+ if not metallic_map_path:
658
+ print(f"Skipping {base_name}: No Metallic map found.")
659
+ continue
660
+
661
+ if not ao_map_path:
662
+ print(f"Skipping {base_name}: No Ambient Occlusion map found.")
663
+ continue
664
+
665
+ alpha_map_path = smoothness_map_path or roughness_map_path
666
+ invert_alpha = roughness_map_path is not None
667
+
668
+ if not alpha_map_path:
669
+ print(f"Skipping {base_name}: No Smoothness or Roughness map found.")
670
+ continue
671
+
672
+ print(f"Packing MSAO ({layout.upper()}) for {base_name}:")
673
+ print(f" Metallic: {metallic_map_path}")
674
+ print(f" AO: {ao_map_path}")
675
+ print(
676
+ f" {'Roughness' if invert_alpha else 'Smoothness'}: {alpha_map_path}"
677
+ )
678
+
679
+ packed_path = MapFactory.pack_msao_texture(
680
+ metallic_map_path,
681
+ ao_map_path,
682
+ alpha_map_path,
683
+ invert_alpha=invert_alpha,
684
+ layout=layout,
685
+ )
686
+ print(f"// Result: {packed_path}")
687
+
688
+ try:
689
+ self.source_dir = FileUtils.format_path(paths[0], "path")
690
+ except Exception:
691
+ pass
692
+
693
+ def b009_init(self, widget):
694
+ """Populate the MSAO unpack toolbutton's option menu (channel layout)."""
695
+ widget.option_box.menu.setTitle("Unpack MSAO")
696
+ widget.option_box.menu.add(
697
+ "QComboBox",
698
+ setObjectName="cmb_msao_unpack_layout",
699
+ setToolTip=(
700
+ "Auto-detect: pick layout from the source image mode (RGBA → HDRP Mask Map, RGB → 3-channel).\n"
701
+ "RGBA: force HDRP Mask Map (R=Metallic, G=AO, B=Detail, A=Smoothness).\n"
702
+ "RGB: force 3-channel parallel layout (R=Metallic, G=Smoothness, B=AO)."
703
+ ),
704
+ )
705
+ widget.option_box.menu.cmb_msao_unpack_layout.add(
706
+ [
707
+ ("Auto-detect", ""),
708
+ ("RGBA (HDRP Mask Map)", "rgba"),
709
+ ("RGB (3-channel)", "rgb"),
710
+ ],
711
+ prefix="Layout:",
712
+ )
713
+
714
+ def b009(self, widget):
715
+ """Unpack Metallic, AO, and Smoothness maps from MSAO textures."""
716
+ msao_paths = self._get_texture_paths(
717
+ title="Select MSAO (MetallicSmoothnessAO) maps to unpack:",
718
+ map_type_filter=["MSAO"],
719
+ )
720
+ if not msao_paths:
721
+ return
722
+
723
+ layout = (
724
+ widget.option_box.menu.cmb_msao_unpack_layout.currentData() or None
725
+ )
726
+
727
+ for msao_path in msao_paths:
728
+ label = (layout or "auto").upper()
729
+ print(f"Unpacking MSAO [{label}]: {msao_path} ..")
730
+
731
+ try:
732
+ metallic_path, ao_path, smoothness_path = (
733
+ MapFactory.unpack_msao_texture(msao_path, layout=layout)
734
+ )
735
+ print(f"// Metallic map: {metallic_path}")
736
+ print(f"// AO map: {ao_path}")
737
+ print(f"// Smoothness map: {smoothness_path}")
738
+
739
+ except Exception as e:
740
+ print(f"// Error unpacking {msao_path}: {e}")
741
+
742
+ try:
743
+ self.source_dir = FileUtils.format_path(msao_paths[0], "path")
744
+ except Exception:
745
+ pass
746
+
747
+ def b013_init(self, widget):
748
+ """Populate the MRAO pack toolbutton's option menu (channel layout)."""
749
+ widget.option_box.menu.setTitle("Pack MRAO")
750
+ widget.option_box.menu.add(
751
+ "QComboBox",
752
+ setObjectName="cmb_mrao_layout",
753
+ setToolTip=(
754
+ "RGB: industry standard (R=Metallic, G=Roughness, B=AO).\n"
755
+ "RGBA: MSAO mirror (R=Metallic, G=AO, B=Detail, A=Roughness)."
756
+ ),
757
+ )
758
+ widget.option_box.menu.cmb_mrao_layout.add(
759
+ [("RGB (3-channel)", "rgb"), ("RGBA (MSAO mirror)", "rgba")],
760
+ prefix="Layout:",
761
+ )
762
+
763
+ def b013(self, widget):
764
+ """Batch pack Metallic, Roughness/Smoothness, and AO into an MRAO texture."""
765
+ paths = self._get_texture_paths(
766
+ title="Select one or more sets of Metallic, Roughness, and Ambient Occlusion maps:",
767
+ )
768
+ if not paths:
769
+ return
770
+
771
+ layout = widget.option_box.menu.cmb_mrao_layout.currentData() or "rgb"
772
+
773
+ texture_sets = MapFactory.group_textures_by_set(paths)
774
+
775
+ for base_name, files in texture_sets.items():
776
+ sorted_maps = MapFactory.sort_images_by_type(files)
777
+
778
+ metallic_map_path = sorted_maps.get("Metallic", [None])[0]
779
+ ao_map_path = sorted_maps.get("Ambient_Occlusion", [None])[0]
780
+ roughness_map_path = sorted_maps.get("Roughness", [None])[0]
781
+ smoothness_map_path = sorted_maps.get("Smoothness", [None])[0]
782
+
783
+ if not metallic_map_path:
784
+ print(f"Skipping {base_name}: No Metallic map found.")
785
+ continue
786
+
787
+ # Prefer Roughness; fall back to Smoothness with inversion.
788
+ roughness_source = roughness_map_path or smoothness_map_path
789
+ invert_roughness = (
790
+ roughness_map_path is None and smoothness_map_path is not None
791
+ )
792
+
793
+ if not roughness_source:
794
+ print(f"Skipping {base_name}: No Roughness or Smoothness map found.")
795
+ continue
796
+
797
+ print(f"Packing MRAO ({layout.upper()}) for {base_name}:")
798
+ print(f" Metallic: {metallic_map_path}")
799
+ print(
800
+ f" {'Smoothness' if invert_roughness else 'Roughness'}: {roughness_source}"
801
+ )
802
+ print(f" AO: {ao_map_path or '(missing — defaults to white)'}")
803
+
804
+ packed_path = MapFactory.pack_mrao_texture(
805
+ metallic_map_path,
806
+ roughness_source,
807
+ ao_map_path,
808
+ invert_roughness=invert_roughness,
809
+ layout=layout,
810
+ )
811
+ print(f"// Result: {packed_path}")
812
+
813
+ try:
814
+ self.source_dir = FileUtils.format_path(paths[0], "path")
815
+ except Exception:
816
+ pass
817
+
818
+ def b014_init(self, widget):
819
+ """Populate the MRAO unpack toolbutton's option menu (channel layout)."""
820
+ widget.option_box.menu.setTitle("Unpack MRAO")
821
+ widget.option_box.menu.add(
822
+ "QComboBox",
823
+ setObjectName="cmb_mrao_unpack_layout",
824
+ setToolTip=(
825
+ "Auto-detect: pick layout from the source image mode (RGB → industry standard, RGBA → MSAO mirror).\n"
826
+ "RGB: force 3-channel industry layout (R=Metallic, G=Roughness, B=AO).\n"
827
+ "RGBA: force MSAO mirror (R=Metallic, G=AO, B=Detail, A=Roughness)."
828
+ ),
829
+ )
830
+ widget.option_box.menu.cmb_mrao_unpack_layout.add(
831
+ [
832
+ ("Auto-detect", ""),
833
+ ("RGB (3-channel)", "rgb"),
834
+ ("RGBA (MSAO mirror)", "rgba"),
835
+ ],
836
+ prefix="Layout:",
837
+ )
838
+
839
+ def b014(self, widget):
840
+ """Unpack Metallic, Roughness, and AO from MRAO textures."""
841
+ mrao_paths = self._get_texture_paths(
842
+ title="Select MRAO (MetallicRoughnessAO) maps to unpack:",
843
+ map_type_filter=["MRAO"],
844
+ )
845
+ if not mrao_paths:
846
+ return
847
+
848
+ layout = (
849
+ widget.option_box.menu.cmb_mrao_unpack_layout.currentData() or None
850
+ )
851
+
852
+ for mrao_path in mrao_paths:
853
+ label = (layout or "auto").upper()
854
+ print(f"Unpacking MRAO [{label}]: {mrao_path} ..")
855
+
856
+ try:
857
+ metallic_path, roughness_path, ao_path = (
858
+ MapFactory.unpack_mrao_texture(mrao_path, layout=layout)
859
+ )
860
+ print(f"// Metallic map: {metallic_path}")
861
+ print(f"// Roughness map: {roughness_path}")
862
+ print(f"// AO map: {ao_path}")
863
+
864
+ except Exception as e:
865
+ print(f"// Error unpacking {mrao_path}: {e}")
866
+
867
+ try:
868
+ self.source_dir = FileUtils.format_path(mrao_paths[0], "path")
869
+ except Exception:
870
+ pass
871
+
872
+ def b015(self):
873
+ """Batch pack AO, Roughness/Smoothness, and Metallic into an ORM texture."""
874
+ paths = self._get_texture_paths(
875
+ title="Select one or more sets of Ambient Occlusion, Roughness, and Metallic maps:",
876
+ )
877
+ if not paths:
878
+ return
879
+
880
+ texture_sets = MapFactory.group_textures_by_set(paths)
881
+
882
+ for base_name, files in texture_sets.items():
883
+ sorted_maps = MapFactory.sort_images_by_type(files)
884
+
885
+ ao_map_path = sorted_maps.get("Ambient_Occlusion", [None])[0]
886
+ metallic_map_path = sorted_maps.get("Metallic", [None])[0]
887
+ roughness_map_path = sorted_maps.get("Roughness", [None])[0]
888
+ smoothness_map_path = sorted_maps.get("Smoothness", [None])[0]
889
+
890
+ # Prefer Roughness; fall back to Smoothness with inversion.
891
+ roughness_source = roughness_map_path or smoothness_map_path
892
+ invert_roughness = (
893
+ roughness_map_path is None and smoothness_map_path is not None
894
+ )
895
+
896
+ if not roughness_source:
897
+ print(f"Skipping {base_name}: No Roughness or Smoothness map found.")
898
+ continue
899
+
900
+ if not metallic_map_path:
901
+ print(f"Skipping {base_name}: No Metallic map found.")
902
+ continue
903
+
904
+ print(f"Packing ORM for {base_name}:")
905
+ print(f" AO (R): {ao_map_path or '(missing — defaults to white)'}")
906
+ print(
907
+ f" {'Smoothness' if invert_roughness else 'Roughness'} (G): {roughness_source}"
908
+ )
909
+ print(f" Metallic (B): {metallic_map_path}")
910
+
911
+ packed_path = MapFactory.pack_orm_texture(
912
+ ao_map_path,
913
+ roughness_source,
914
+ metallic_map_path,
915
+ invert_roughness=invert_roughness,
916
+ )
917
+ print(f"// Result: {packed_path}")
918
+
919
+ try:
920
+ self.source_dir = FileUtils.format_path(paths[0], "path")
921
+ except Exception:
922
+ pass
923
+
924
+ def b016(self):
925
+ """Unpack AO, Roughness, and Metallic from ORM textures."""
926
+ orm_paths = self._get_texture_paths(
927
+ title="Select ORM (Occlusion Roughness Metallic) maps to unpack:",
928
+ map_type_filter=["ORM"],
929
+ )
930
+ if not orm_paths:
931
+ return
932
+
933
+ for orm_path in orm_paths:
934
+ print(f"Unpacking ORM: {orm_path} ..")
935
+
936
+ try:
937
+ ao_path, roughness_path, metallic_path = (
938
+ MapFactory.unpack_orm_texture(orm_path)
939
+ )
940
+ print(f"// AO map: {ao_path}")
941
+ print(f"// Roughness map: {roughness_path}")
942
+ print(f"// Metallic map: {metallic_path}")
943
+
944
+ except Exception as e:
945
+ print(f"// Error unpacking {orm_path}: {e}")
946
+
947
+ try:
948
+ self.source_dir = FileUtils.format_path(orm_paths[0], "path")
949
+ except Exception:
950
+ pass
951
+
952
+ def b010(self):
953
+ """Convert Smoothness maps to Roughness maps."""
954
+ smoothness_paths = self._get_texture_paths(
955
+ title="Select Smoothness maps to convert to Roughness:",
956
+ map_type_filter=["Smoothness"],
957
+ )
958
+ if not smoothness_paths:
959
+ return
960
+
961
+ for smoothness_path in smoothness_paths:
962
+ print(f"Converting Smoothness to Roughness: {smoothness_path} ..")
963
+
964
+ try:
965
+ roughness_path = MapFactory.convert_smoothness_to_roughness(
966
+ smoothness_path
967
+ )
968
+ print(f"// Result: {roughness_path}")
969
+
970
+ except Exception as e:
971
+ print(f"// Error converting {smoothness_path}: {e}")
972
+
973
+ try:
974
+ self.source_dir = FileUtils.format_path(smoothness_paths[0], "path")
975
+ except Exception:
976
+ pass
977
+
978
+ def b011(self):
979
+ """Convert Roughness maps to Smoothness maps."""
980
+ roughness_paths = self._get_texture_paths(
981
+ title="Select Roughness maps to convert to Smoothness:",
982
+ map_type_filter=["Roughness"],
983
+ )
984
+ if not roughness_paths:
985
+ return
986
+
987
+ for roughness_path in roughness_paths:
988
+ print(f"Converting Roughness to Smoothness: {roughness_path} ..")
989
+
990
+ try:
991
+ smoothness_path = MapFactory.convert_roughness_to_smoothness(
992
+ roughness_path
993
+ )
994
+ print(f"// Result: {smoothness_path}")
995
+
996
+ except Exception as e:
997
+ print(f"// Error converting {roughness_path}: {e}")
998
+
999
+ try:
1000
+ self.source_dir = FileUtils.format_path(roughness_paths[0], "path")
1001
+ except Exception:
1002
+ pass
1003
+
1004
+ def b012(self):
1005
+ """Batch prepare textures for PBR workflow using MapFactory.
1006
+
1007
+ Supports multiple workflows:
1008
+ - Standard PBR (separate maps)
1009
+ - Unity URP (Albedo+Transparency, Metallic+Smoothness)
1010
+ - Unity HDRP (MSAO Mask Map)
1011
+ - Unreal Engine
1012
+ - glTF 2.0
1013
+ - Godot
1014
+ - Specular/Glossiness
1015
+ """
1016
+ # Get texture paths
1017
+ texture_paths = self._get_texture_paths(
1018
+ title="Select texture maps for PBR workflow preparation:",
1019
+ )
1020
+ if not texture_paths:
1021
+ return
1022
+
1023
+ # Present workflow options
1024
+ workflow_options = [
1025
+ "Standard PBR (Separate Maps)",
1026
+ "Unity URP (Packed: Albedo+Alpha, Metallic+Smoothness)",
1027
+ "Unity HDRP (Mask Map: MSAO)",
1028
+ "MRAO (Packed: Metallic+Roughness+AO)",
1029
+ "Unreal Engine (BaseColor+Alpha)",
1030
+ "glTF 2.0 (Separate Maps)",
1031
+ "Godot (Separate Maps)",
1032
+ "Specular/Glossiness Workflow",
1033
+ ]
1034
+
1035
+ from qtpy.QtWidgets import QInputDialog
1036
+
1037
+ workflow, ok = QInputDialog.getItem(
1038
+ None,
1039
+ "Select PBR Workflow",
1040
+ "Choose target workflow:",
1041
+ workflow_options,
1042
+ 0,
1043
+ False,
1044
+ )
1045
+ if not ok:
1046
+ return
1047
+
1048
+ # Map workflow to config
1049
+ workflow_configs = {
1050
+ "Standard PBR (Separate Maps)": {
1051
+ "albedo_transparency": False,
1052
+ "metallic_smoothness": False,
1053
+ "mask_map": False,
1054
+ "normal_type": "OpenGL",
1055
+ "output_extension": "png",
1056
+ },
1057
+ "Unity URP (Packed: Albedo+Alpha, Metallic+Smoothness)": {
1058
+ "albedo_transparency": True,
1059
+ "metallic_smoothness": True,
1060
+ "mask_map": False,
1061
+ "normal_type": "OpenGL",
1062
+ "output_extension": "png",
1063
+ },
1064
+ "Unity HDRP (Mask Map: MSAO)": {
1065
+ "albedo_transparency": False,
1066
+ "metallic_smoothness": False,
1067
+ "mask_map": True,
1068
+ "normal_type": "OpenGL",
1069
+ "output_extension": "png",
1070
+ },
1071
+ "MRAO (Packed: Metallic+Roughness+AO)": {
1072
+ "albedo_transparency": False,
1073
+ "metallic_smoothness": False,
1074
+ "mask_map": False,
1075
+ "mrao_map": True,
1076
+ "normal_type": "OpenGL",
1077
+ "output_extension": "png",
1078
+ },
1079
+ "Unreal Engine (BaseColor+Alpha)": {
1080
+ "albedo_transparency": True,
1081
+ "metallic_smoothness": False,
1082
+ "mask_map": False,
1083
+ "normal_type": "DirectX",
1084
+ "output_extension": "png",
1085
+ },
1086
+ "glTF 2.0 (Separate Maps)": {
1087
+ "albedo_transparency": False,
1088
+ "metallic_smoothness": False,
1089
+ "mask_map": False,
1090
+ "normal_type": "OpenGL",
1091
+ "output_extension": "png",
1092
+ },
1093
+ "Godot (Separate Maps)": {
1094
+ "albedo_transparency": False,
1095
+ "metallic_smoothness": False,
1096
+ "mask_map": False,
1097
+ "normal_type": "OpenGL",
1098
+ "output_extension": "png",
1099
+ },
1100
+ "Specular/Glossiness Workflow": {
1101
+ "albedo_transparency": False,
1102
+ "metallic_smoothness": True,
1103
+ "mask_map": False,
1104
+ "normal_type": "OpenGL",
1105
+ "output_extension": "png",
1106
+ },
1107
+ }
1108
+
1109
+ config = workflow_configs.get(workflow)
1110
+ if not config:
1111
+ print(f"Unknown workflow: {workflow}")
1112
+ return
1113
+
1114
+ print(f"\n{'='*60}")
1115
+ print(f"Preparing textures for {workflow}")
1116
+ print(f"{'='*60}\n")
1117
+
1118
+ try:
1119
+ results = MapFactory.prepare_maps(
1120
+ texture_paths,
1121
+ callback=print,
1122
+ **config,
1123
+ )
1124
+
1125
+ if isinstance(results, dict):
1126
+ print(f"Processed {len(results)} texture sets.")
1127
+ for base_name, maps in results.items():
1128
+ print(f"\n✓ Set: {base_name}")
1129
+ for m in maps:
1130
+ print(f" - {FileUtils.format_path(m, 'name')}")
1131
+ else:
1132
+ print("\n✓ Processed single set.")
1133
+ for m in results:
1134
+ print(f" - {FileUtils.format_path(m, 'name')}")
1135
+
1136
+ except Exception as e:
1137
+ print(f"Error during batch processing: {e}")
1138
+
1139
+ print(f"{'='*60}")
1140
+ print(f"Workflow preparation complete!")
1141
+ print(f"{'='*60}\n")
1142
+
1143
+ try:
1144
+ self.source_dir = FileUtils.format_path(texture_paths[0], "path")
1145
+ except Exception:
1146
+ pass