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,421 @@
|
|
|
1
|
+
"""Colored logging and timing utilities for notso-glb."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# ANSI color codes
|
|
12
|
+
class Colors:
|
|
13
|
+
"""ANSI escape codes for terminal colors."""
|
|
14
|
+
|
|
15
|
+
RESET = "\033[0m"
|
|
16
|
+
BOLD = "\033[1m"
|
|
17
|
+
DIM = "\033[2m"
|
|
18
|
+
|
|
19
|
+
# Foreground colors
|
|
20
|
+
RED = "\033[31m"
|
|
21
|
+
GREEN = "\033[32m"
|
|
22
|
+
YELLOW = "\033[33m"
|
|
23
|
+
BLUE = "\033[34m"
|
|
24
|
+
MAGENTA = "\033[35m"
|
|
25
|
+
CYAN = "\033[36m"
|
|
26
|
+
WHITE = "\033[37m"
|
|
27
|
+
|
|
28
|
+
# Bright variants
|
|
29
|
+
BRIGHT_RED = "\033[91m"
|
|
30
|
+
BRIGHT_GREEN = "\033[92m"
|
|
31
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
32
|
+
BRIGHT_BLUE = "\033[94m"
|
|
33
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
34
|
+
BRIGHT_CYAN = "\033[96m"
|
|
35
|
+
|
|
36
|
+
# Background colors
|
|
37
|
+
BG_RED = "\033[41m"
|
|
38
|
+
BG_GREEN = "\033[42m"
|
|
39
|
+
BG_YELLOW = "\033[43m"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _supports_color() -> bool:
|
|
43
|
+
"""Check if terminal supports color output."""
|
|
44
|
+
if not hasattr(sys.stdout, "isatty"):
|
|
45
|
+
return False
|
|
46
|
+
if not sys.stdout.isatty():
|
|
47
|
+
return False
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_USE_COLOR = _supports_color()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _c(color: str, text: str) -> str:
|
|
55
|
+
"""Apply color to text if supported."""
|
|
56
|
+
if not _USE_COLOR:
|
|
57
|
+
return text
|
|
58
|
+
return f"{color}{text}{Colors.RESET}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def bold(text: str) -> str:
|
|
62
|
+
"""Make text bold."""
|
|
63
|
+
return _c(Colors.BOLD, text)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dim(text: str) -> str:
|
|
67
|
+
"""Make text dim."""
|
|
68
|
+
return _c(Colors.DIM, text)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def red(text: str) -> str:
|
|
72
|
+
"""Color text red."""
|
|
73
|
+
return _c(Colors.RED, text)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def green(text: str) -> str:
|
|
77
|
+
"""Color text green."""
|
|
78
|
+
return _c(Colors.GREEN, text)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def yellow(text: str) -> str:
|
|
82
|
+
"""Color text yellow."""
|
|
83
|
+
return _c(Colors.YELLOW, text)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def blue(text: str) -> str:
|
|
87
|
+
"""Color text blue."""
|
|
88
|
+
return _c(Colors.BLUE, text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cyan(text: str) -> str:
|
|
92
|
+
"""Color text cyan."""
|
|
93
|
+
return _c(Colors.CYAN, text)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def magenta(text: str) -> str:
|
|
97
|
+
"""Color text magenta."""
|
|
98
|
+
return _c(Colors.MAGENTA, text)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bright_green(text: str) -> str:
|
|
102
|
+
"""Color text bright green."""
|
|
103
|
+
return _c(Colors.BRIGHT_GREEN, text)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def bright_yellow(text: str) -> str:
|
|
107
|
+
"""Color text bright yellow."""
|
|
108
|
+
return _c(Colors.BRIGHT_YELLOW, text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def bright_red(text: str) -> str:
|
|
112
|
+
"""Color text bright red."""
|
|
113
|
+
return _c(Colors.BRIGHT_RED, text)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def bright_cyan(text: str) -> str:
|
|
117
|
+
"""Color text bright cyan."""
|
|
118
|
+
return _c(Colors.BRIGHT_CYAN, text)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Log level formatting
|
|
122
|
+
def log_info(msg: str) -> None:
|
|
123
|
+
"""Print info message."""
|
|
124
|
+
print(f" {cyan('INFO')} {msg}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def log_ok(msg: str) -> None:
|
|
128
|
+
"""Print success message."""
|
|
129
|
+
print(f" {bright_green('OK')} {msg}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def log_warn(msg: str) -> None:
|
|
133
|
+
"""Print warning message."""
|
|
134
|
+
print(f" {bright_yellow('WARN')} {msg}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def log_error(msg: str) -> None:
|
|
138
|
+
"""Print error message."""
|
|
139
|
+
print(f" {bright_red('ERROR')} {msg}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def log_debug(msg: str) -> None:
|
|
143
|
+
"""Print debug message (dimmed)."""
|
|
144
|
+
print(f" {dim('DEBUG')} {dim(msg)}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def log_step(current: int, total: int, msg: str) -> None:
|
|
148
|
+
"""Print step progress message."""
|
|
149
|
+
step_str = f"[{current}/{total}]"
|
|
150
|
+
print(f"\n{cyan(step_str)} {msg}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def log_detail(msg: str, indent: int = 6) -> None:
|
|
154
|
+
"""Print indented detail message."""
|
|
155
|
+
print(f"{' ' * indent}{msg}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def log_timing(msg: str, seconds: float) -> None:
|
|
159
|
+
"""Print timing message with formatted duration."""
|
|
160
|
+
time_str = format_duration(seconds)
|
|
161
|
+
print(f" {dim('TIME')} {msg}: {bright_cyan(time_str)}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# Separators and headers
|
|
165
|
+
def print_header(title: str, char: str = "=", width: int = 60) -> None:
|
|
166
|
+
"""Print a header with decorative borders."""
|
|
167
|
+
border = char * width
|
|
168
|
+
print(f"\n{cyan(border)}")
|
|
169
|
+
print(f" {bold(title)}")
|
|
170
|
+
print(f"{cyan(border)}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def print_section(title: str, char: str = "-", width: int = 60) -> None:
|
|
174
|
+
"""Print a section header."""
|
|
175
|
+
border = char * width
|
|
176
|
+
print(f"\n{dim(border)}")
|
|
177
|
+
print(f" {title}")
|
|
178
|
+
print(f"{dim(border)}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def print_warning_box(
|
|
182
|
+
title: str, warnings: list[str], severity: str = "WARNING"
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Print a warning box with colored border."""
|
|
185
|
+
border_char = "!" if severity == "CRITICAL" else "~"
|
|
186
|
+
color_fn = bright_red if severity == "CRITICAL" else bright_yellow
|
|
187
|
+
border = border_char * 60
|
|
188
|
+
|
|
189
|
+
print(f"\n{color_fn(border)}")
|
|
190
|
+
print(f" {color_fn(title)}")
|
|
191
|
+
print(f"{color_fn(border)}")
|
|
192
|
+
for w in warnings:
|
|
193
|
+
print(f" {w}")
|
|
194
|
+
print(f"{color_fn(border)}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Timing utilities
|
|
198
|
+
def format_duration(seconds: float) -> str:
|
|
199
|
+
"""Format seconds into human-readable duration."""
|
|
200
|
+
if seconds < 0.001:
|
|
201
|
+
return f"{seconds * 1000000:.0f}μs"
|
|
202
|
+
elif seconds < 1:
|
|
203
|
+
return f"{seconds * 1000:.1f}ms"
|
|
204
|
+
elif seconds < 60:
|
|
205
|
+
return f"{seconds:.2f}s"
|
|
206
|
+
else:
|
|
207
|
+
mins = int(seconds // 60)
|
|
208
|
+
secs = seconds % 60
|
|
209
|
+
return f"{mins}m {secs:.1f}s"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class TimingResult:
|
|
214
|
+
"""Result from a timed operation."""
|
|
215
|
+
|
|
216
|
+
elapsed: float
|
|
217
|
+
message: str
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@contextmanager
|
|
221
|
+
def timed(description: str, print_on_exit: bool = True) -> Iterator[TimingResult]:
|
|
222
|
+
"""Context manager for timing operations.
|
|
223
|
+
|
|
224
|
+
Usage:
|
|
225
|
+
with timed("Processing meshes") as t:
|
|
226
|
+
do_work()
|
|
227
|
+
# Automatically prints timing on exit
|
|
228
|
+
|
|
229
|
+
# Or capture without printing:
|
|
230
|
+
with timed("Processing", print_on_exit=False) as t:
|
|
231
|
+
do_work()
|
|
232
|
+
print(f"Took {t.elapsed}s")
|
|
233
|
+
"""
|
|
234
|
+
result = TimingResult(elapsed=0.0, message=description)
|
|
235
|
+
start = time.perf_counter()
|
|
236
|
+
try:
|
|
237
|
+
yield result
|
|
238
|
+
finally:
|
|
239
|
+
result.elapsed = time.perf_counter() - start
|
|
240
|
+
if print_on_exit:
|
|
241
|
+
log_timing(description, result.elapsed)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class StepTimer:
|
|
245
|
+
"""Track timing for multiple steps in a pipeline."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, total_steps: int) -> None:
|
|
248
|
+
self.total = total_steps
|
|
249
|
+
self.current = 0
|
|
250
|
+
self.timings: list[tuple[str, float]] = []
|
|
251
|
+
self._step_start: float = 0.0
|
|
252
|
+
self._total_start: float = time.perf_counter()
|
|
253
|
+
|
|
254
|
+
def step(self, message: str) -> None:
|
|
255
|
+
"""Start a new step, recording timing for previous step."""
|
|
256
|
+
now = time.perf_counter()
|
|
257
|
+
|
|
258
|
+
# Record previous step timing
|
|
259
|
+
if self.current > 0 and self._step_start > 0:
|
|
260
|
+
elapsed = now - self._step_start
|
|
261
|
+
if self.timings:
|
|
262
|
+
prev_name = self.timings[-1][0]
|
|
263
|
+
self.timings[-1] = (prev_name, elapsed)
|
|
264
|
+
|
|
265
|
+
self.current += 1
|
|
266
|
+
self._step_start = now
|
|
267
|
+
self.timings.append((message, 0.0))
|
|
268
|
+
log_step(self.current, self.total, message)
|
|
269
|
+
|
|
270
|
+
def finish(self) -> None:
|
|
271
|
+
"""Finish timing and record final step."""
|
|
272
|
+
now = time.perf_counter()
|
|
273
|
+
if self._step_start > 0 and self.timings:
|
|
274
|
+
elapsed = now - self._step_start
|
|
275
|
+
prev_name = self.timings[-1][0]
|
|
276
|
+
self.timings[-1] = (prev_name, elapsed)
|
|
277
|
+
|
|
278
|
+
def final_message(self, message: str, success: bool = True) -> None:
|
|
279
|
+
"""Print final step message without timing (for completion messages)."""
|
|
280
|
+
self.current += 1
|
|
281
|
+
color = bright_green if success else bright_red
|
|
282
|
+
step_str = f"[{self.current}/{self.total}]"
|
|
283
|
+
print(f"\n{color(step_str)} {message}")
|
|
284
|
+
|
|
285
|
+
def total_elapsed(self) -> float:
|
|
286
|
+
"""Get total elapsed time since timer started."""
|
|
287
|
+
return time.perf_counter() - self._total_start
|
|
288
|
+
|
|
289
|
+
def print_summary(self) -> None:
|
|
290
|
+
"""Print timing summary for all steps."""
|
|
291
|
+
print_section("Timing Summary", char="-", width=50)
|
|
292
|
+
for name, elapsed in self.timings:
|
|
293
|
+
time_str = format_duration(elapsed)
|
|
294
|
+
# Right-align timing
|
|
295
|
+
padding = 40 - len(name)
|
|
296
|
+
print(f" {name}{' ' * max(1, padding)}{bright_cyan(time_str)}")
|
|
297
|
+
print(f"{dim('-' * 50)}")
|
|
298
|
+
total = self.total_elapsed()
|
|
299
|
+
print(f" {bold('Total')}{' ' * 33}{bright_green(format_duration(total))}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# Result formatting
|
|
303
|
+
def format_count(count: int, singular: str, plural: str | None = None) -> str:
|
|
304
|
+
"""Format count with proper singular/plural form."""
|
|
305
|
+
if plural is None:
|
|
306
|
+
plural = singular + "s"
|
|
307
|
+
word = singular if count == 1 else plural
|
|
308
|
+
return f"{count:,} {word}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def format_bytes(size: int) -> str:
|
|
312
|
+
"""Format byte size in human-readable form."""
|
|
313
|
+
if size < 1024:
|
|
314
|
+
return f"{size} B"
|
|
315
|
+
elif size < 1024 * 1024:
|
|
316
|
+
return f"{size / 1024:.1f} KB"
|
|
317
|
+
elif size < 1024 * 1024 * 1024:
|
|
318
|
+
return f"{size / 1024 / 1024:.2f} MB"
|
|
319
|
+
else:
|
|
320
|
+
return f"{size / 1024 / 1024 / 1024:.2f} GB"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def format_delta(before: int, after: int, unit: str = "") -> str:
|
|
324
|
+
"""Format a before/after change with color."""
|
|
325
|
+
diff = after - before
|
|
326
|
+
if diff == 0:
|
|
327
|
+
return dim("no change")
|
|
328
|
+
elif diff < 0:
|
|
329
|
+
return bright_green(f"-{abs(diff):,}{unit}")
|
|
330
|
+
else:
|
|
331
|
+
return bright_red(f"+{diff:,}{unit}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _process_blender_output(output: str) -> None:
|
|
335
|
+
"""Process captured Blender output, showing only warnings/errors."""
|
|
336
|
+
for line in output.splitlines():
|
|
337
|
+
# Pass through warnings and errors with our formatting
|
|
338
|
+
if "| WARNING:" in line:
|
|
339
|
+
msg = line.split("| WARNING:", 1)[-1].strip()
|
|
340
|
+
log_warn(f"[Blender] {msg}")
|
|
341
|
+
elif "| ERROR:" in line:
|
|
342
|
+
msg = line.split("| ERROR:", 1)[-1].strip()
|
|
343
|
+
log_error(f"[Blender] {msg}")
|
|
344
|
+
# Suppress: INFO lines, DracoDecoder lines, and other noise
|
|
345
|
+
# (all other lines are discarded)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@contextmanager
|
|
349
|
+
def filter_blender_output() -> Iterator[None]:
|
|
350
|
+
"""Filter Blender output: suppress INFO/debug, pass through WARNING/ERROR.
|
|
351
|
+
|
|
352
|
+
Redirects at the OS file descriptor level to capture native C output
|
|
353
|
+
(like DracoDecoder) that bypasses Python's sys.stdout/stderr.
|
|
354
|
+
|
|
355
|
+
Output is post-processed after the operation completes.
|
|
356
|
+
"""
|
|
357
|
+
import tempfile
|
|
358
|
+
|
|
359
|
+
# Save original file descriptors
|
|
360
|
+
stdout_fd = sys.stdout.fileno()
|
|
361
|
+
stderr_fd = sys.stderr.fileno()
|
|
362
|
+
saved_stdout_fd = os.dup(stdout_fd)
|
|
363
|
+
saved_stderr_fd = os.dup(stderr_fd)
|
|
364
|
+
|
|
365
|
+
# Create temp files to capture output
|
|
366
|
+
stdout_tmp = tempfile.TemporaryFile(mode="w+", encoding="utf-8")
|
|
367
|
+
stderr_tmp = tempfile.TemporaryFile(mode="w+", encoding="utf-8")
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
# Flush Python buffers before redirecting
|
|
371
|
+
sys.stdout.flush()
|
|
372
|
+
sys.stderr.flush()
|
|
373
|
+
|
|
374
|
+
# Redirect file descriptors to temp files
|
|
375
|
+
os.dup2(stdout_tmp.fileno(), stdout_fd)
|
|
376
|
+
os.dup2(stderr_tmp.fileno(), stderr_fd)
|
|
377
|
+
yield
|
|
378
|
+
finally:
|
|
379
|
+
# Flush ALL C-level stdio buffers (captures DracoDecoder subprocess output)
|
|
380
|
+
# fflush(NULL) flushes all open output streams
|
|
381
|
+
import ctypes
|
|
382
|
+
import time
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
libc = ctypes.CDLL(None)
|
|
386
|
+
libc.fflush(None)
|
|
387
|
+
except (OSError, AttributeError):
|
|
388
|
+
pass # Fallback: just use fsync
|
|
389
|
+
|
|
390
|
+
# Small delay to ensure subprocess output is flushed to temp files
|
|
391
|
+
# DracoDecoder subprocess may still be writing when export returns
|
|
392
|
+
time.sleep(0.05)
|
|
393
|
+
os.fsync(stdout_fd)
|
|
394
|
+
os.fsync(stderr_fd)
|
|
395
|
+
|
|
396
|
+
# Restore original file descriptors
|
|
397
|
+
os.dup2(saved_stdout_fd, stdout_fd)
|
|
398
|
+
os.dup2(saved_stderr_fd, stderr_fd)
|
|
399
|
+
os.close(saved_stdout_fd)
|
|
400
|
+
os.close(saved_stderr_fd)
|
|
401
|
+
|
|
402
|
+
# Read and process captured output
|
|
403
|
+
stdout_tmp.seek(0)
|
|
404
|
+
stderr_tmp.seek(0)
|
|
405
|
+
_process_blender_output(stdout_tmp.read())
|
|
406
|
+
_process_blender_output(stderr_tmp.read())
|
|
407
|
+
|
|
408
|
+
stdout_tmp.close()
|
|
409
|
+
stderr_tmp.close()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@contextmanager
|
|
413
|
+
def suppress_stdout() -> Iterator[None]:
|
|
414
|
+
"""Completely suppress stdout (redirect to /dev/null)."""
|
|
415
|
+
with open(os.devnull, "w") as devnull:
|
|
416
|
+
old_stdout = sys.stdout
|
|
417
|
+
sys.stdout = devnull
|
|
418
|
+
try:
|
|
419
|
+
yield
|
|
420
|
+
finally:
|
|
421
|
+
sys.stdout = old_stdout
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Naming and string utility functions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sanitize_gltf_name(name: str) -> str:
|
|
7
|
+
"""
|
|
8
|
+
Simulate how glTF export sanitizes names for JS identifiers.
|
|
9
|
+
Dots, spaces, dashes become underscores. Leading digits get prefix.
|
|
10
|
+
"""
|
|
11
|
+
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
|
12
|
+
if sanitized and sanitized[0].isdigit():
|
|
13
|
+
sanitized = "_" + sanitized
|
|
14
|
+
return sanitized
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def nearest_power_of_two(n: int) -> int:
|
|
18
|
+
"""Round to nearest power of two."""
|
|
19
|
+
if n <= 1:
|
|
20
|
+
return 1
|
|
21
|
+
bit_len = (n - 1).bit_length()
|
|
22
|
+
lower = 1 << (bit_len - 1)
|
|
23
|
+
upper = 1 << bit_len
|
|
24
|
+
return lower if (n - lower) < (upper - n) else upper
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""WASM-based gltfpack integration using wasmtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .runner import get_gltfpack, reset_gltfpack, run_gltfpack_wasm
|
|
8
|
+
from .runtime import GltfpackWasm, _get_wasm_path
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"GltfpackWasm",
|
|
12
|
+
"get_gltfpack",
|
|
13
|
+
"get_wasm_path",
|
|
14
|
+
"is_available",
|
|
15
|
+
"reset_gltfpack",
|
|
16
|
+
"run_gltfpack_wasm",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_wasm_path() -> Path:
|
|
21
|
+
"""Get path to bundled gltfpack.wasm."""
|
|
22
|
+
return _get_wasm_path()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def is_available() -> bool:
|
|
26
|
+
"""Check if WASM runtime (wasmtime) is importable and WASM exists."""
|
|
27
|
+
try:
|
|
28
|
+
import wasmtime # noqa: F401
|
|
29
|
+
|
|
30
|
+
return _get_wasm_path().exists()
|
|
31
|
+
except ImportError:
|
|
32
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
Binary file
|
notso_glb/wasm/py.typed
ADDED
|
File without changes
|
notso_glb/wasm/runner.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""WASM gltfpack runner - executes gltfpack via WASM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .runtime import GltfpackWasm
|
|
8
|
+
|
|
9
|
+
# Singleton instance
|
|
10
|
+
_gltfpack: GltfpackWasm | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_gltfpack() -> GltfpackWasm:
|
|
14
|
+
"""Get or create singleton GltfpackWasm instance."""
|
|
15
|
+
global _gltfpack
|
|
16
|
+
if _gltfpack is None:
|
|
17
|
+
_gltfpack = GltfpackWasm()
|
|
18
|
+
return _gltfpack
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def reset_gltfpack() -> None:
|
|
22
|
+
"""Reset singleton instance (for testing/cleanup)."""
|
|
23
|
+
global _gltfpack
|
|
24
|
+
_gltfpack = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_output_path(input_path: Path, output_path: str | Path | None) -> Path:
|
|
28
|
+
"""Resolve output path, defaulting to input_packed.glb."""
|
|
29
|
+
if output_path is not None:
|
|
30
|
+
return Path(output_path)
|
|
31
|
+
stem = input_path.stem
|
|
32
|
+
if stem.endswith("_packed"):
|
|
33
|
+
stem = stem[:-7]
|
|
34
|
+
return input_path.parent / f"{stem}_packed{input_path.suffix}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_args(
|
|
38
|
+
mesh_compress: bool,
|
|
39
|
+
simplify_ratio: float | None,
|
|
40
|
+
) -> tuple[list[str], str | None]:
|
|
41
|
+
"""Build gltfpack args, return (args, error_message)."""
|
|
42
|
+
args: list[str] = []
|
|
43
|
+
if mesh_compress:
|
|
44
|
+
args.append("-cc")
|
|
45
|
+
if simplify_ratio is not None:
|
|
46
|
+
if not (0.0 <= simplify_ratio <= 1.0):
|
|
47
|
+
return [], f"simplify_ratio must be [0.0, 1.0]: {simplify_ratio}"
|
|
48
|
+
args.extend(["-si", str(simplify_ratio)])
|
|
49
|
+
return args, None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _execute(
|
|
53
|
+
input_path: Path,
|
|
54
|
+
output_path: Path,
|
|
55
|
+
args: list[str],
|
|
56
|
+
) -> tuple[bool, Path, str]:
|
|
57
|
+
"""Execute gltfpack WASM and handle errors."""
|
|
58
|
+
try:
|
|
59
|
+
gltfpack = get_gltfpack()
|
|
60
|
+
input_data = input_path.read_bytes()
|
|
61
|
+
success, output_data, log = gltfpack.pack(
|
|
62
|
+
input_data,
|
|
63
|
+
input_name=input_path.name,
|
|
64
|
+
output_name=output_path.name,
|
|
65
|
+
args=args,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if not success:
|
|
69
|
+
return False, output_path, f"gltfpack failed: {log}"
|
|
70
|
+
|
|
71
|
+
output_path.write_bytes(output_data)
|
|
72
|
+
return True, output_path, "Success"
|
|
73
|
+
|
|
74
|
+
except UnicodeDecodeError as e:
|
|
75
|
+
return False, output_path, f"Log decode error: {e}"
|
|
76
|
+
except OSError as e:
|
|
77
|
+
return False, output_path, f"File I/O error: {e}"
|
|
78
|
+
except (ValueError, TypeError) as e:
|
|
79
|
+
return False, output_path, f"Argument error: {e}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_gltfpack_wasm(
|
|
83
|
+
input_path: str | Path,
|
|
84
|
+
output_path: str | Path | None = None,
|
|
85
|
+
*,
|
|
86
|
+
texture_compress: bool = True,
|
|
87
|
+
mesh_compress: bool = True,
|
|
88
|
+
simplify_ratio: float | None = None,
|
|
89
|
+
texture_quality: int | None = None,
|
|
90
|
+
) -> tuple[bool, Path, str]:
|
|
91
|
+
"""
|
|
92
|
+
Run gltfpack via WASM on a GLB/glTF file.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
input_path: Input GLB/glTF file
|
|
96
|
+
output_path: Output path (default: replaces input with _packed suffix)
|
|
97
|
+
texture_compress: Enable texture compression (-tc)
|
|
98
|
+
mesh_compress: Enable mesh compression (-cc)
|
|
99
|
+
simplify_ratio: Simplify meshes to ratio (0.0-1.0), None = no simplify
|
|
100
|
+
texture_quality: Texture quality 1-10, None = default
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (success, output_path, message)
|
|
104
|
+
"""
|
|
105
|
+
from . import get_wasm_path, is_available
|
|
106
|
+
|
|
107
|
+
input_path = Path(input_path)
|
|
108
|
+
|
|
109
|
+
if not is_available():
|
|
110
|
+
wasm_path = get_wasm_path()
|
|
111
|
+
if not wasm_path.exists():
|
|
112
|
+
return (
|
|
113
|
+
False,
|
|
114
|
+
input_path,
|
|
115
|
+
f"WASM file not found at {wasm_path}. "
|
|
116
|
+
"Run: uv run scripts/update_wasm.py",
|
|
117
|
+
)
|
|
118
|
+
return False, input_path, "WASM runtime not available (wasmtime not installed)"
|
|
119
|
+
|
|
120
|
+
if not input_path.is_file():
|
|
121
|
+
return False, input_path, f"Input file not found: {input_path}"
|
|
122
|
+
|
|
123
|
+
resolved_output = _resolve_output_path(input_path, output_path)
|
|
124
|
+
|
|
125
|
+
if texture_compress:
|
|
126
|
+
from notso_glb.utils.logging import log_warn
|
|
127
|
+
|
|
128
|
+
log_warn("WASM gltfpack lacks BasisU support, skipping texture compression")
|
|
129
|
+
|
|
130
|
+
# texture_quality only applies with -tc, which WASM doesn't support
|
|
131
|
+
del texture_quality
|
|
132
|
+
|
|
133
|
+
args, error = _build_args(mesh_compress, simplify_ratio)
|
|
134
|
+
if error:
|
|
135
|
+
return False, input_path, error
|
|
136
|
+
|
|
137
|
+
return _execute(input_path, resolved_output, args)
|