notso-glb 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,49 @@
1
+ """Utility functions for Blender scene access."""
2
+
3
+ from typing import cast
4
+
5
+ import bpy
6
+ from bpy.types import Armature, Mesh, Object, Scene, ViewLayer
7
+
8
+
9
+ def get_scene() -> Scene:
10
+ """Get the current scene, raising if None."""
11
+ scene = bpy.context.scene
12
+ if scene is None:
13
+ raise RuntimeError("No active scene")
14
+ return scene
15
+
16
+
17
+ def get_view_layer() -> ViewLayer:
18
+ """Get the current view layer, raising if None."""
19
+ view_layer = bpy.context.view_layer
20
+ if view_layer is None:
21
+ raise RuntimeError("No active view layer")
22
+ return view_layer
23
+
24
+
25
+ def get_mesh_data(obj: Object) -> Mesh:
26
+ """Get mesh data from an object, assuming obj.type == 'MESH'."""
27
+ return cast(Mesh, obj.data)
28
+
29
+
30
+ def get_armature_data(obj: Object) -> Armature:
31
+ """Get armature data from an object, assuming obj.type == 'ARMATURE'."""
32
+ return cast(Armature, obj.data)
33
+
34
+
35
+ def get_scene_stats() -> dict[str, int]:
36
+ """Get current scene statistics."""
37
+ meshes = [o for o in bpy.data.objects if o.type == "MESH"]
38
+ armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
39
+
40
+ total_verts = sum(len(get_mesh_data(o).vertices) for o in meshes)
41
+ total_bones = sum(len(get_armature_data(a).bones) for a in armatures)
42
+ total_actions = len(bpy.data.actions)
43
+
44
+ return {
45
+ "meshes": len(meshes),
46
+ "vertices": total_verts,
47
+ "bones": total_bones,
48
+ "actions": total_actions,
49
+ }
@@ -0,0 +1,41 @@
1
+ """Constants and thresholds for GLB optimization."""
2
+
3
+ from pathlib import Path
4
+ from typing import TypedDict
5
+
6
+ # Bloat detection thresholds for web mascots
7
+ BLOAT_THRESHOLDS: dict[str, int] = {
8
+ "prop_warning": 1000, # Non-skinned mesh > this = warning
9
+ "prop_critical": 2000, # Non-skinned mesh > this = critical
10
+ "repetitive_islands": 10, # More islands than this...
11
+ "repetitive_verts": 50, # ...with more verts each = repetitive detail
12
+ "scene_total": 15000, # Total scene verts for web
13
+ }
14
+
15
+
16
+ class OptimizationConfig(TypedDict):
17
+ """Configuration for GLB optimization."""
18
+
19
+ output_path: Path | None
20
+ use_draco: bool
21
+ use_webp: bool
22
+ max_texture_size: int
23
+ force_pot_textures: bool
24
+ analyze_animations: bool
25
+ check_bloat: bool
26
+ experimental_autofix: bool
27
+ quiet: bool
28
+
29
+
30
+ # Default configuration for optimization
31
+ DEFAULT_CONFIG: OptimizationConfig = {
32
+ "output_path": None, # None = auto (same folder as input)
33
+ "use_draco": True, # Mesh compression
34
+ "use_webp": True, # WebP textures (smaller than PNG)
35
+ "max_texture_size": 1024, # Resize textures (0 = no resize)
36
+ "force_pot_textures": False, # Force power-of-two dimensions
37
+ "analyze_animations": True, # Find static bones (slow but saves MB)
38
+ "check_bloat": True, # Detect unreasonable mesh complexity
39
+ "experimental_autofix": False, # [EXPERIMENTAL] Auto-decimate props
40
+ "quiet": True, # Minimize console output
41
+ }
@@ -0,0 +1,273 @@
1
+ """Wrapper for gltfpack mesh/texture compression tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import TypeAlias
10
+
11
+ # Environment variables to force a specific backend (for testing)
12
+ ENV_FORCE_NATIVE: str = "NOTSO_GLB_FORCE_GLTFPACK_NATIVE"
13
+ ENV_FORCE_WASM: str = "NOTSO_GLB_FORCE_GLTFPACK_WASM"
14
+
15
+ # Result type alias for clarity
16
+ GltfpackResult: TypeAlias = tuple[bool, Path, str]
17
+
18
+
19
+ def find_gltfpack() -> str | None:
20
+ """Find gltfpack executable in PATH."""
21
+ return shutil.which("gltfpack")
22
+
23
+
24
+ def _wasm_available() -> bool:
25
+ """Check if WASM fallback is available."""
26
+ try:
27
+ from notso_glb.wasm import is_available
28
+
29
+ return is_available()
30
+ except (ImportError, OSError):
31
+ return False
32
+
33
+
34
+ def _select_backend(
35
+ input_path: Path,
36
+ prefer_wasm: bool,
37
+ gltfpack: str | None,
38
+ ) -> tuple[bool | None, GltfpackResult | None]:
39
+ """
40
+ Select backend based on env vars and availability.
41
+
42
+ Returns:
43
+ (use_wasm, error_result) - use_wasm is None if error_result is set.
44
+ """
45
+ force_native = os.environ.get(ENV_FORCE_NATIVE, "").lower() in ("1", "true", "yes")
46
+ force_wasm = os.environ.get(ENV_FORCE_WASM, "").lower() in ("1", "true", "yes")
47
+
48
+ if force_native and force_wasm:
49
+ return None, (False, input_path, "Cannot force both native and WASM backends")
50
+
51
+ if force_native:
52
+ if not gltfpack:
53
+ return None, (
54
+ False,
55
+ input_path,
56
+ f"{ENV_FORCE_NATIVE} set but native gltfpack not found",
57
+ )
58
+ return False, None
59
+
60
+ if force_wasm:
61
+ if not _wasm_available():
62
+ return None, (
63
+ False,
64
+ input_path,
65
+ f"{ENV_FORCE_WASM} set but WASM runtime unavailable",
66
+ )
67
+ return True, None
68
+
69
+ if prefer_wasm:
70
+ if _wasm_available():
71
+ return True, None
72
+ if gltfpack:
73
+ from notso_glb.utils.logging import log_warn
74
+
75
+ log_warn("prefer_wasm=True but WASM unavailable, falling back to native")
76
+ return False, None
77
+ return None, (
78
+ False,
79
+ input_path,
80
+ "prefer_wasm=True but WASM unavailable and no native fallback",
81
+ )
82
+
83
+ if not gltfpack:
84
+ if _wasm_available():
85
+ return True, None
86
+ return None, (
87
+ False,
88
+ input_path,
89
+ "gltfpack not found and WASM fallback unavailable",
90
+ )
91
+
92
+ return False, None
93
+
94
+
95
+ def _resolve_output_path(input_path: Path, output_path: str | Path | None) -> Path:
96
+ """Resolve output path, defaulting to input_packed.glb."""
97
+ if output_path is not None:
98
+ return Path(output_path)
99
+ stem = input_path.stem
100
+ if stem.endswith("_packed"):
101
+ stem = stem[:-7]
102
+ return input_path.parent / f"{stem}_packed{input_path.suffix}"
103
+
104
+
105
+ def _validate_simplify_ratio(
106
+ ratio: float | None,
107
+ input_path: Path,
108
+ ) -> tuple[float | None, GltfpackResult | None]:
109
+ """Validate simplify_ratio, return (validated_value, error_or_none)."""
110
+ if ratio is None:
111
+ return None, None
112
+ try:
113
+ ratio = float(ratio)
114
+ except (TypeError, ValueError):
115
+ return None, (
116
+ False,
117
+ input_path,
118
+ f"simplify_ratio must be a number, got {type(ratio).__name__}",
119
+ )
120
+ if not (0.0 <= ratio <= 1.0):
121
+ return None, (
122
+ False,
123
+ input_path,
124
+ f"simplify_ratio must be in [0.0, 1.0], got {ratio}",
125
+ )
126
+ return ratio, None
127
+
128
+
129
+ def _validate_texture_quality(
130
+ quality: int | float | None,
131
+ input_path: Path,
132
+ ) -> tuple[int | None, GltfpackResult | None]:
133
+ """Validate texture_quality, return (validated_value, error_or_none)."""
134
+ if quality is None:
135
+ return None, None
136
+ # Reject bool explicitly (bool is subclass of int)
137
+ if isinstance(quality, bool):
138
+ return None, (
139
+ False,
140
+ input_path,
141
+ "texture_quality must be an integer, bool provided",
142
+ )
143
+ # Reject non-integer floats
144
+ if isinstance(quality, float):
145
+ if not quality.is_integer():
146
+ return None, (
147
+ False,
148
+ input_path,
149
+ "texture_quality must be an integer, non-integer float provided",
150
+ )
151
+ quality = int(quality)
152
+ else:
153
+ try:
154
+ quality = int(quality)
155
+ except (TypeError, ValueError):
156
+ return None, (
157
+ False,
158
+ input_path,
159
+ f"texture_quality must be an integer, got {type(quality).__name__}",
160
+ )
161
+ if not (1 <= quality <= 10):
162
+ return None, (
163
+ False,
164
+ input_path,
165
+ f"texture_quality must be in [1, 10], got {quality}",
166
+ )
167
+ return quality, None
168
+
169
+
170
+ def _run_native_gltfpack(
171
+ cmd: list[str],
172
+ output_path: Path,
173
+ ) -> GltfpackResult:
174
+ """Execute native gltfpack subprocess."""
175
+ try:
176
+ result = subprocess.run(
177
+ cmd,
178
+ capture_output=True,
179
+ text=True,
180
+ timeout=300,
181
+ )
182
+ if result.returncode != 0:
183
+ error_msg = (
184
+ result.stderr.strip() or result.stdout.strip() or "Unknown error"
185
+ )
186
+ return False, output_path, f"gltfpack failed: {error_msg}"
187
+ if not output_path.exists():
188
+ return False, output_path, "gltfpack completed but output file not found"
189
+ return True, output_path, "Success"
190
+ except subprocess.TimeoutExpired:
191
+ return False, output_path, "gltfpack timed out after 5 minutes"
192
+ except subprocess.SubprocessError as e:
193
+ return False, output_path, f"gltfpack subprocess error: {e}"
194
+ except OSError as e:
195
+ return False, output_path, f"gltfpack OS error (cmd={cmd}): {e}"
196
+
197
+
198
+ def run_gltfpack(
199
+ input_path: str | Path,
200
+ output_path: str | Path | None = None,
201
+ *,
202
+ texture_compress: bool = True,
203
+ mesh_compress: bool = True,
204
+ simplify_ratio: float | None = None,
205
+ texture_quality: int | None = None,
206
+ prefer_wasm: bool = False,
207
+ ) -> GltfpackResult:
208
+ """
209
+ Run gltfpack on a GLB/glTF file.
210
+
211
+ Args:
212
+ input_path: Input GLB/glTF file
213
+ output_path: Output path (default: replaces input with _packed suffix)
214
+ texture_compress: Enable texture compression (-tc)
215
+ mesh_compress: Enable mesh compression (-cc)
216
+ simplify_ratio: Simplify meshes to ratio (0.0-1.0), None = no simplify
217
+ texture_quality: Texture quality 1-10, None = default
218
+ prefer_wasm: Prefer WASM over native binary (default: False)
219
+
220
+ Returns:
221
+ Tuple of (success, output_path, message)
222
+ """
223
+ input_path = Path(input_path)
224
+ gltfpack = find_gltfpack()
225
+
226
+ # Step 1: Select backend
227
+ use_wasm, error = _select_backend(input_path, prefer_wasm, gltfpack)
228
+ if error:
229
+ return error
230
+
231
+ # Step 2: Delegate to WASM if selected
232
+ if use_wasm:
233
+ from notso_glb.wasm import run_gltfpack_wasm
234
+
235
+ return run_gltfpack_wasm(
236
+ input_path,
237
+ output_path,
238
+ texture_compress=texture_compress,
239
+ mesh_compress=mesh_compress,
240
+ simplify_ratio=simplify_ratio,
241
+ texture_quality=texture_quality,
242
+ )
243
+
244
+ # Step 3: Validate input file
245
+ if not input_path.is_file():
246
+ return False, input_path, f"Input file not found or is not a file: {input_path}"
247
+
248
+ # Step 4: Resolve output path
249
+ output_path = _resolve_output_path(input_path, output_path)
250
+
251
+ # Step 5: Validate optional arguments
252
+ simplify_ratio, error = _validate_simplify_ratio(simplify_ratio, input_path)
253
+ if error:
254
+ return error
255
+
256
+ texture_quality, error = _validate_texture_quality(texture_quality, input_path)
257
+ if error:
258
+ return error
259
+
260
+ # Step 6: Build command
261
+ assert gltfpack is not None
262
+ cmd: list[str] = [gltfpack, "-i", str(input_path), "-o", str(output_path)]
263
+ if texture_compress:
264
+ cmd.append("-tc")
265
+ if mesh_compress:
266
+ cmd.append("-cc")
267
+ if simplify_ratio is not None:
268
+ cmd.extend(["-si", str(simplify_ratio)])
269
+ if texture_quality is not None:
270
+ cmd.extend(["-tq", str(texture_quality)])
271
+
272
+ # Step 7: Execute
273
+ return _run_native_gltfpack(cmd, output_path)