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,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,8 @@
1
+ """WASI error codes and constants."""
2
+
3
+ # WASI error codes
4
+ WASI_EBADF: int = 8
5
+ WASI_EFAULT: int = 21
6
+ WASI_EINVAL: int = 28
7
+ WASI_EIO: int = 29
8
+ WASI_ENOSYS: int = 52
@@ -0,0 +1 @@
1
+ 1.0.0
Binary file
File without changes
@@ -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)