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.
- notso_glb/__init__.py +38 -0
- notso_glb/__main__.py +6 -0
- notso_glb/analyzers/__init__.py +20 -0
- notso_glb/analyzers/bloat.py +117 -0
- notso_glb/analyzers/bones.py +100 -0
- notso_glb/analyzers/duplicates.py +71 -0
- notso_glb/analyzers/skinned_mesh.py +47 -0
- notso_glb/analyzers/uv_maps.py +59 -0
- notso_glb/cleaners/__init__.py +23 -0
- notso_glb/cleaners/bones.py +49 -0
- notso_glb/cleaners/duplicates.py +110 -0
- notso_glb/cleaners/mesh.py +183 -0
- notso_glb/cleaners/textures.py +116 -0
- notso_glb/cleaners/uv_maps.py +29 -0
- notso_glb/cleaners/vertex_groups.py +34 -0
- notso_glb/cli.py +330 -0
- notso_glb/exporters/__init__.py +8 -0
- notso_glb/exporters/gltf.py +647 -0
- notso_glb/utils/__init__.py +20 -0
- notso_glb/utils/blender.py +49 -0
- notso_glb/utils/constants.py +41 -0
- notso_glb/utils/gltfpack.py +273 -0
- notso_glb/utils/logging.py +421 -0
- notso_glb/utils/naming.py +24 -0
- notso_glb/wasm/__init__.py +32 -0
- notso_glb/wasm/constants.py +8 -0
- notso_glb/wasm/gltfpack.version +1 -0
- notso_glb/wasm/gltfpack.wasm +0 -0
- notso_glb/wasm/py.typed +0 -0
- notso_glb/wasm/runner.py +137 -0
- notso_glb/wasm/runtime.py +244 -0
- notso_glb/wasm/wasi.py +347 -0
- notso_glb-0.1.0.dist-info/METADATA +150 -0
- notso_glb-0.1.0.dist-info/RECORD +36 -0
- notso_glb-0.1.0.dist-info/WHEEL +4 -0
- notso_glb-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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)
|