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,443 @@
1
+ # !/usr/bin/python
2
+ # coding=utf-8
3
+ """UI slot bindings for the map_compositor window.
4
+
5
+ Slots own the UI state and compose a :class:`MapCompositor` engine.
6
+ Engine status messages flow through ``self.engine.logger`` (a LoggingMixin
7
+ logger) which has a default ``StreamHandler`` for console output; we
8
+ attach uitk's :class:`TextEditLogHandler` alongside it so the UI message
9
+ panel auto-scrolls. Progress-bar updates use a thin callback.
10
+ """
11
+
12
+ import logging
13
+ import os
14
+ from typing import Optional
15
+
16
+ import pythontk as ptk
17
+ from pythontk.core_utils.logging_mixin import LevelAwareFormatter
18
+ from qtpy.QtWidgets import QPushButton
19
+ from uitk.widgets.textEditLogHandler import TextEditLogHandler
20
+
21
+ from pythontk import BatchResult, MapCompositor, NormalOutputMode
22
+
23
+ _DOCS_URL = "https://github.com/m3trik/extapps#readme"
24
+
25
+
26
+ def _build_intro() -> str:
27
+ """One-time intro panel: minimal quickstart + link to full docs.
28
+
29
+ The full filename-suffix table used to live here but dwarfed the
30
+ actual instructions — readers had to scroll past ~30 rows of alias
31
+ lists to find the basic export settings. Moved to the GitHub README;
32
+ this panel now stays a single screen.
33
+ """
34
+ return (
35
+ "<u>Quickstart</u><br>"
36
+ "&nbsp;&nbsp;1. Set the <b>source</b> and <b>destination</b> directories.<br>"
37
+ "&nbsp;&nbsp;2. (Optional) Set a <b>map name</b> prefix.<br>"
38
+ "&nbsp;&nbsp;3. Click <b>Combine Maps</b>.<br><br>"
39
+ "<u>Required Substance Painter Export Settings</u><br>"
40
+ "&nbsp;&nbsp;Output Template: <b>Document channels</b><br>"
41
+ "&nbsp;&nbsp;Padding: <b>Dilation + transparent</b> or "
42
+ "<b>Dilation + default background color</b><br><br>"
43
+ f'<span style="color:#888888">'
44
+ f"Full filename-suffix table and detailed docs: "
45
+ f'<a href="{_DOCS_URL}" style="color:#88AACC">{_DOCS_URL}</a>'
46
+ "</span>"
47
+ )
48
+
49
+
50
+ class MapCompositorSlots:
51
+ """UI slot handler. Composes a :class:`MapCompositor` via ``self.engine``."""
52
+
53
+ msg_intro = _build_intro()
54
+
55
+ # (display label, NormalOutputMode) — order shown in the header combo.
56
+ NORMAL_MODE_CHOICES = (
57
+ ("Both (auto-convert)", NormalOutputMode.BOTH),
58
+ ("OpenGL only", NormalOutputMode.OPENGL_ONLY),
59
+ ("DirectX only", NormalOutputMode.DIRECTX_ONLY),
60
+ ("No conversion", NormalOutputMode.NONE),
61
+ )
62
+
63
+ # "None" disables the post-composite workflow pass; the remaining entries
64
+ # are populated from pythontk's MapRegistry at runtime so the menu stays
65
+ # in sync with the registry's WF.* workflow keys.
66
+ _NO_TEMPLATE_LABEL = "None (composite only)"
67
+
68
+ def __init__(self, switchboard) -> None:
69
+ self.sb = switchboard
70
+ self.ui = self.sb.loaded_ui.map_compositor
71
+
72
+ self.engine = MapCompositor(progress_callback=self._on_progress)
73
+ logger = self.engine.logger
74
+ # Class-level logger — sweep stale widget handlers from prior sessions.
75
+ for h in list(logger.handlers):
76
+ if hasattr(h, "widget"):
77
+ logger.removeHandler(h)
78
+ # Attach directly; ``_set_text_handler`` would force this handler
79
+ # process-wide. monospace=False keeps the intro's rich-text font;
80
+ # log records still render monospace via TextEditLogHandler's <span>.
81
+ logger.setLevel(logging.INFO)
82
+ handler = TextEditLogHandler(self.ui.txt003, monospace=False)
83
+ handler.setLevel(logging.INFO)
84
+ handler.setFormatter(LevelAwareFormatter(logger=logger, strip_html=False))
85
+ logger.addHandler(handler)
86
+
87
+ self.default_toolTip_txt000 = self.ui.txt000.toolTip()
88
+ self.default_toolTip_txt001 = self.ui.txt001.toolTip()
89
+
90
+ self.ui.txt003.setText(self.msg_intro)
91
+ self.ui.footer.setDefaultStatusText("Ready.")
92
+
93
+ # The primary action ("Combine Maps") lives in the footer with a
94
+ # styled background so it stands out from the status text. Connect
95
+ # to the existing slot method directly — Switchboard won't auto-wire
96
+ # it because the button isn't part of the .ui tree.
97
+ self.combine_btn = QPushButton("Combine Maps")
98
+ self.combine_btn.setToolTip("Start the compositing process.")
99
+ self.combine_btn.clicked.connect(self.b002)
100
+ self.ui.footer.add_widget(
101
+ self.combine_btn, side="right", background=True, rounded=False
102
+ )
103
+
104
+ # --- engine pass-through (back-compat with previous public API) ---
105
+ @property
106
+ def removeNormalMap(self) -> bool:
107
+ return self.engine.remove_normal_map
108
+
109
+ @removeNormalMap.setter
110
+ def removeNormalMap(self, value: bool) -> None:
111
+ self.engine.remove_normal_map = value
112
+
113
+ # --- input/output text-field properties ---
114
+ @property
115
+ def input_dir(self) -> str:
116
+ return self.ui.txt000.text()
117
+
118
+ @property
119
+ def output_dir(self) -> str:
120
+ return self.ui.txt001.text()
121
+
122
+ @property
123
+ def map_name(self) -> str:
124
+ return self.ui.txt002.text()
125
+
126
+ # --- shared helpers ---
127
+ def _bind_recent_values(
128
+ self,
129
+ widget,
130
+ settings_key: str,
131
+ legacy_key: str,
132
+ *,
133
+ auto_record: bool = False,
134
+ ):
135
+ """Attach a RecentValuesOption and seed it from legacy QSettings."""
136
+ from uitk.widgets.optionBox.options.recent_values import RecentValuesOption
137
+
138
+ opt = RecentValuesOption(
139
+ wrapped_widget=widget,
140
+ settings_key=settings_key,
141
+ max_recent=10,
142
+ auto_record=auto_record,
143
+ )
144
+ widget.option_box.add_option(opt)
145
+ if not opt.recent_values:
146
+ for v in self.ui.settings.value(legacy_key, []):
147
+ if v != "/":
148
+ opt.add_recent_value(v)
149
+ return opt
150
+
151
+ def _bind_dir_actions(self, widget, browse_title: str):
152
+ """Attach Set (browse-directory) and Open (reveal-in-explorer) buttons.
153
+
154
+ Returns the Open ActionOption so the caller can toggle its enabled
155
+ state from the text-change handler.
156
+ """
157
+ from uitk.widgets.optionBox.options.action import ActionOption
158
+ from uitk.widgets.optionBox.options.browse import BrowseOption
159
+
160
+ open_opt = ActionOption(
161
+ wrapped_widget=widget,
162
+ callback=lambda: self._open_dir(widget.text()),
163
+ icon="open_external",
164
+ tooltip="Open this directory in the file explorer.",
165
+ )
166
+ widget.option_box.add_option(open_opt)
167
+
168
+ browse_opt = BrowseOption(
169
+ wrapped_widget=widget,
170
+ mode="directory",
171
+ title=browse_title,
172
+ tooltip="Browse for a directory.",
173
+ )
174
+ widget.option_box.add_option(browse_opt)
175
+
176
+ return open_opt
177
+
178
+ @staticmethod
179
+ def _open_dir(path: Optional[str]) -> None:
180
+ try:
181
+ os.startfile(path)
182
+ except (FileNotFoundError, TypeError):
183
+ pass
184
+
185
+ def _on_dir_validated(self, ok: bool, text: str, open_opt):
186
+ """Toggle the Open button in response to validated dir text."""
187
+ open_opt.widget.setEnabled(bool(text and ok))
188
+
189
+ def _on_progress(self, percent: float) -> None:
190
+ """Engine→UI progress bar bridge (routes through the footer).
191
+
192
+ Goes through ``Footer.update_progress`` rather than poking
193
+ ``progress_bar.setValue`` directly — the bar is created with
194
+ ``auto_hide=True`` and only becomes visible after the matching
195
+ ``start_progress`` call in ``process()``. ``ProgressBar.update_progress``
196
+ already pumps the event loop, so no extra ``processEvents`` here.
197
+ """
198
+ self.ui.footer.update_progress(int(percent))
199
+
200
+ # --- widget init handlers ---
201
+ def header_init(self, widget):
202
+ """Populate the header menu with global options."""
203
+ widget.menu.add(
204
+ "QCheckBox",
205
+ setText="Optimize output",
206
+ setObjectName="chk_optimize",
207
+ setChecked=self.engine.optimize_output,
208
+ setToolTip=(
209
+ "Run ImgUtils.optimize_texture on each saved map "
210
+ "(enforces map-type bit depth and mode)."
211
+ ),
212
+ stateChanged=self._on_optimize_toggled,
213
+ )
214
+ widget.menu.add(
215
+ "QComboBox",
216
+ setObjectName="cmb_normal_mode",
217
+ setToolTip=(
218
+ "Choose which DirectX/OpenGL normal map variant(s) to output. "
219
+ "Note: when an output template is selected, the template's "
220
+ "required normal format may add a sibling file alongside the "
221
+ "compositor's output."
222
+ ),
223
+ addItems=[label for label, _mode in self.NORMAL_MODE_CHOICES],
224
+ )
225
+ # Sync the combo to the engine's current value before connecting the
226
+ # signal, so the initial setCurrentIndex doesn't re-trigger the slot.
227
+ modes = [m for _label, m in self.NORMAL_MODE_CHOICES]
228
+ widget.menu.cmb_normal_mode.setCurrentIndex(
229
+ modes.index(self.engine.normal_output_mode)
230
+ )
231
+ widget.menu.cmb_normal_mode.currentIndexChanged.connect(
232
+ self._on_normal_mode_changed
233
+ )
234
+
235
+ # Output template — names sourced from pythontk's MapRegistry so the
236
+ # menu mirrors the registry's WF.* workflow keys. None = composite
237
+ # only (no post-pass). When set, after compositing finishes the
238
+ # engine runs MapFactory.prepare_maps with the matching workflow
239
+ # preset to pack/rename files for the target engine.
240
+ presets = ptk.MapRegistry().get_workflow_presets()
241
+ self._template_choices = (self._NO_TEMPLATE_LABEL, *presets.keys())
242
+ widget.menu.add(
243
+ "QComboBox",
244
+ setObjectName="cmb_output_template",
245
+ setToolTip=(
246
+ "Post-process composited output for a target workflow. "
247
+ "Packs/renames the files for the chosen engine (e.g. Unity "
248
+ "HDRP packs Metallic/AO/Smoothness into an MSAO MaskMap). "
249
+ "Original composited files stay on disk alongside the "
250
+ "workflow output."
251
+ ),
252
+ addItems=list(self._template_choices),
253
+ )
254
+ # Pre-select to match the engine field (default: None).
255
+ current = self.engine.output_template or self._NO_TEMPLATE_LABEL
256
+ try:
257
+ widget.menu.cmb_output_template.setCurrentIndex(
258
+ self._template_choices.index(current)
259
+ )
260
+ except ValueError:
261
+ widget.menu.cmb_output_template.setCurrentIndex(0)
262
+ widget.menu.cmb_output_template.currentIndexChanged.connect(
263
+ self._on_output_template_changed
264
+ )
265
+
266
+ def _on_optimize_toggled(self, state) -> None:
267
+ # Qt.Checked is 2 in PySide6, 2 in PySide2 — robust check via bool.
268
+ self.engine.optimize_output = bool(state)
269
+
270
+ def _on_normal_mode_changed(self, index: int) -> None:
271
+ _label, mode = self.NORMAL_MODE_CHOICES[index]
272
+ self.engine.normal_output_mode = mode
273
+
274
+ def _on_output_template_changed(self, index: int) -> None:
275
+ choice = self._template_choices[index]
276
+ self.engine.output_template = (
277
+ None if choice == self._NO_TEMPLATE_LABEL else choice
278
+ )
279
+
280
+ def txt000_init(self, widget):
281
+ """Init Source Directory"""
282
+ self._recent_input_dirs = self._bind_recent_values(
283
+ widget,
284
+ "map_compositor_input_dirs",
285
+ "prev_input_dirs",
286
+ auto_record=True,
287
+ )
288
+ self._open_input_dir = self._bind_dir_actions(
289
+ widget, browse_title="Select a directory containing image files."
290
+ )
291
+ widget.set_validator(
292
+ "dir",
293
+ invalid_tooltip="Invalid directory",
294
+ empty_tooltip=self.default_toolTip_txt000,
295
+ )
296
+ widget.validated.connect(
297
+ lambda ok, text: self._on_dir_validated(ok, text, self._open_input_dir)
298
+ )
299
+
300
+ def txt001_init(self, widget):
301
+ """Init Destination Directory"""
302
+ self._recent_output_dirs = self._bind_recent_values(
303
+ widget,
304
+ "map_compositor_output_dirs",
305
+ "prev_output_dirs",
306
+ auto_record=True,
307
+ )
308
+ self._open_output_dir = self._bind_dir_actions(
309
+ widget, browse_title="Select an output directory."
310
+ )
311
+ widget.set_validator(
312
+ "dir",
313
+ invalid_tooltip="Invalid directory",
314
+ empty_tooltip=self.default_toolTip_txt001,
315
+ )
316
+ widget.validated.connect(
317
+ lambda ok, text: self._on_dir_validated(ok, text, self._open_output_dir)
318
+ )
319
+
320
+ def txt002_init(self, widget):
321
+ """Init Map Name"""
322
+ self._recent_map_names = self._bind_recent_values(
323
+ widget,
324
+ "map_compositor_map_names",
325
+ "prev_map_names",
326
+ auto_record=True,
327
+ )
328
+
329
+ # --- button handlers ---
330
+ def b002(self):
331
+ """Combine Maps"""
332
+ self.ui.txt003.clear()
333
+ self.ui.footer.setStatusText("Loading maps …")
334
+ self.engine.logger.info("Loading maps ..", preset="italic")
335
+ self.sb.app.processEvents()
336
+ images = ptk.get_images(self.input_dir)
337
+ self.process(images, self.input_dir, self.output_dir, self.map_name)
338
+
339
+ # --- orchestration ---
340
+ def process(self, images, input_dir, output_dir, map_name=None):
341
+ """Validate dirs, prepare sorted-image groups, and drive the engine."""
342
+ if not (input_dir and output_dir):
343
+ self.engine.logger.error(
344
+ "You must specify a source and destination directory."
345
+ )
346
+ self.ui.footer.setStatusText("Source and destination directories required.")
347
+ return
348
+
349
+ invalid_dir = next(
350
+ (d for d in (input_dir, output_dir) if not ptk.is_valid(d, "dir")),
351
+ None,
352
+ )
353
+ if invalid_dir:
354
+ self.engine.logger.error(f"Directory is invalid: <b>{invalid_dir}</b>.")
355
+ self.ui.footer.setStatusText(f"Invalid directory: {invalid_dir}")
356
+ return
357
+
358
+ if not map_name:
359
+ map_name = ptk.format_path(input_dir, "dir")
360
+
361
+ sorted_images = ptk.MapFactory.sort_images_by_type(images)
362
+ has_normal_pair = ptk.MapFactory.contains_map_types(
363
+ sorted_images, ["Normal_DirectX", "Normal_OpenGL"]
364
+ )
365
+ # +1 for the auto-generated complementary normal map.
366
+ total_maps_extra = 1 if has_normal_pair else 0
367
+
368
+ if self.engine.remove_normal_map and has_normal_pair:
369
+ normal = next(
370
+ (
371
+ k
372
+ for k in sorted_images
373
+ if ptk.MapFactory.resolve_map_type(k) == "Normal"
374
+ ),
375
+ None,
376
+ )
377
+ if normal:
378
+ del sorted_images[normal]
379
+
380
+ # When the user has both DX *and* GL sources but only wants one
381
+ # format, drop the redundant one — otherwise the engine would
382
+ # process each independently and the iteration order would decide
383
+ # which content survives (the second-processed format overwrites
384
+ # the first via the auto-invert path).
385
+ mode = self.engine.normal_output_mode
386
+ if (
387
+ mode is NormalOutputMode.OPENGL_ONLY
388
+ and "Normal_OpenGL" in sorted_images
389
+ and "Normal_DirectX" in sorted_images
390
+ ):
391
+ del sorted_images["Normal_DirectX"]
392
+ elif (
393
+ mode is NormalOutputMode.DIRECTX_ONLY
394
+ and "Normal_OpenGL" in sorted_images
395
+ and "Normal_DirectX" in sorted_images
396
+ ):
397
+ del sorted_images["Normal_OpenGL"]
398
+
399
+ # Drop maps superseded by present packed maps (e.g. ORM → drops
400
+ # Metallic/Roughness/AO; MSAO → drops more). In-place mutation.
401
+ # NB: pythontk's sort_images_by_type already aliases legacy variants
402
+ # like Mixed_AO into Ambient_Occlusion, so no manual rename is needed.
403
+ ptk.MapFactory.filter_redundant_maps(sorted_images)
404
+
405
+ total_layers = sum(len(v) for v in sorted_images.values())
406
+ total_maps = len(sorted_images) + total_maps_extra
407
+
408
+ self.engine.logger.info(
409
+ f"Sorting <b>{total_layers}</b> images, into "
410
+ f"<b>{total_maps}</b> maps ..",
411
+ preset="italic",
412
+ )
413
+ # Reveal the footer's slim progress bar; engine ticks flow into
414
+ # update_progress via _on_progress. finish_progress() in every
415
+ # exit path auto-hides the bar after a short delay.
416
+ self.ui.footer.start_progress(
417
+ total=100,
418
+ text=f"Compositing {total_maps} maps from {total_layers} layers …",
419
+ )
420
+
421
+ try:
422
+ result = self.engine.process_batch(sorted_images, output_dir, map_name)
423
+ except Exception as e:
424
+ self.engine.logger.error(
425
+ f"Operation encountered the following error:<br>{e}"
426
+ )
427
+ self.ui.footer.finish_progress(f"Failed: {e}")
428
+ raise
429
+
430
+ if result is BatchResult.MASK_FAILURE:
431
+ self.engine.logger.error(
432
+ "Unable to create masks from the source images.<br>"
433
+ "To create a mask, at least one set of source maps need a "
434
+ "transparent or single color background,<br>alternatively a "
435
+ "set of mask maps can be added to the source folder. "
436
+ "ex. &lt;map_name&gt;_mask.png"
437
+ )
438
+ self.ui.footer.finish_progress(
439
+ "Mask creation failed — see message panel for details."
440
+ )
441
+ else:
442
+ self.engine.logger.success("COMPLETED.")
443
+ self.ui.footer.finish_progress(f"Wrote {total_maps} map(s) to {output_dir}")
@@ -0,0 +1,23 @@
1
+ # !/usr/bin/python
2
+ # coding=utf-8
3
+ """Map Converter — texture conversion, channel packing, PBR-workflow prep.
4
+
5
+ Engine logic lives in :class:`pythontk.ImgUtils` and
6
+ :class:`pythontk.MapFactory`; this package holds only the Switchboard
7
+ panel and launcher.
8
+ """
9
+ from pythontk.core_utils.module_resolver import bootstrap_package
10
+
11
+ __package__ = "extapps.map_converter"
12
+
13
+
14
+ DEFAULT_INCLUDE = {
15
+ "launcher": ["MapConverterUI"],
16
+ "slots": ["MapConverterSlots"],
17
+ }
18
+
19
+
20
+ bootstrap_package(globals(), include=DEFAULT_INCLUDE)
21
+
22
+
23
+ __all__ = ["MapConverterUI", "MapConverterSlots"]
@@ -0,0 +1,39 @@
1
+ # !/usr/bin/python
2
+ # coding=utf-8
3
+ """Application shell for the Map Converter UI.
4
+
5
+ Engine logic lives in :class:`pythontk.ImgUtils` / :class:`pythontk.MapFactory`
6
+ and slot bindings in :mod:`extapps.map_converter.slots`; this module
7
+ only assembles the Switchboard-driven UI and provides the script
8
+ entry point.
9
+ """
10
+
11
+
12
+ class MapConverterUI:
13
+ """Standalone launcher. Constructing the class returns a configured UI.
14
+
15
+ ``__new__`` is overridden to return the wired Switchboard UI directly,
16
+ so ``MapConverterUI()`` yields the UI (not an instance). Use this when
17
+ running outside a host DCC. Hosts that need to inject a
18
+ ``texture_provider`` should register :class:`MapConverterSlots`
19
+ themselves rather than going through this launcher.
20
+ """
21
+
22
+ def __new__(cls):
23
+ from uitk import Switchboard
24
+ from extapps.map_converter.slots import MapConverterSlots
25
+
26
+ sb = Switchboard(ui_source="map_converter.ui", slot_source=MapConverterSlots)
27
+ ui = sb.loaded_ui.map_converter
28
+
29
+ ui.set_attributes(WA_TranslucentBackground=True)
30
+ ui.set_flags(FramelessWindowHint=True)
31
+ ui.style.set(theme="dark", style_class="translucentBgWithBorder")
32
+ ui.header.config_buttons("menu", "minimize", "hide")
33
+ return ui
34
+
35
+
36
+ # -----------------------------------------------------------------------------
37
+
38
+ if __name__ == "__main__":
39
+ MapConverterUI().show(pos="screen", app_exec=True)