@galfus/gltf-loader 0.0.0-stage → 0.31.1-alpha

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.
package/src/zip.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * In-memory ZIP archive unpacking and URI resolver for multi-file glTF assets.
3
+ */
4
+
5
+ import { unzipSync } from 'fflate';
6
+
7
+ /**
8
+ * Checks if the binary buffer starts with the standard PKZIP magic bytes (PK\x03\x04).
9
+ *
10
+ * @param buffer Raw binary buffer to check.
11
+ * @returns True if the buffer is a valid PKZIP container.
12
+ */
13
+ export function isZipContainer(buffer: Uint8Array): boolean {
14
+ if (buffer.byteLength < 4) return false;
15
+ return (
16
+ buffer[0] === 0x50 && // 'P'
17
+ buffer[1] === 0x4b && // 'K'
18
+ ((buffer[2] === 0x03 && buffer[3] === 0x04) || // Standard local file header
19
+ (buffer[2] === 0x05 && buffer[3] === 0x06) || // Empty archive end of central dir
20
+ (buffer[2] === 0x07 && buffer[3] === 0x08)) // Spanned archive marker
21
+ );
22
+ }
23
+
24
+ /**
25
+ * Unpacks a ZIP archive into a normalized in-memory file dictionary.
26
+ *
27
+ * Directory entries are discarded; forward slashes are standardized.
28
+ *
29
+ * @param buffer The raw ZIP bytes.
30
+ * @returns Map of normalized file paths to their decompressed bytes.
31
+ */
32
+ export function unpackZipArchive(buffer: Uint8Array): Map<string, Uint8Array> {
33
+ const unzipped = unzipSync(buffer);
34
+ const fileMap = new Map<string, Uint8Array>();
35
+
36
+ for (const rawPath of Object.keys(unzipped)) {
37
+ const data = unzipped[rawPath];
38
+ if (!data || data.byteLength === 0) {
39
+ // Could be an empty directory entry or empty file
40
+ if (rawPath.endsWith('/') || rawPath.endsWith('\\')) {
41
+ continue;
42
+ }
43
+ }
44
+
45
+ // Normalize Windows backslashes to forward slashes and strip leading slashes
46
+ const normalized = rawPath.replace(/\\/g, '/').replace(/^\/+/, '');
47
+ if (normalized.length > 0 && !normalized.endsWith('/')) {
48
+ fileMap.set(normalized, data!);
49
+ }
50
+ }
51
+
52
+ return fileMap;
53
+ }
54
+
55
+ /**
56
+ * Locates the primary .gltf or .glb file inside an unpacked archive.
57
+ *
58
+ * Prioritizes standard names like `scene.gltf` or `model.gltf`, or the shallowest glTF file.
59
+ *
60
+ * @param fileMap The unpacked files map.
61
+ * @returns The path of the primary glTF file, or null if none found.
62
+ */
63
+ export function findPrimaryGltfPath(fileMap: Map<string, Uint8Array>): string | null {
64
+ const gltfCandidates: string[] = [];
65
+
66
+ for (const path of fileMap.keys()) {
67
+ const lower = path.toLowerCase();
68
+ if (lower.endsWith('.gltf') || lower.endsWith('.glb')) {
69
+ gltfCandidates.push(path);
70
+ }
71
+ }
72
+
73
+ if (gltfCandidates.length === 0) {
74
+ return null;
75
+ }
76
+
77
+ // Check for canonical model names first
78
+ const preferred = gltfCandidates.find((p) => {
79
+ const base = p.split('/').pop()?.toLowerCase();
80
+ return (
81
+ base === 'scene.gltf' || base === 'model.gltf' || base === 'scene.glb' || base === 'model.glb'
82
+ );
83
+ });
84
+ if (preferred) return preferred;
85
+
86
+ // Otherwise sort by directory depth (shallowest first) and then by name
87
+ gltfCandidates.sort((a, b) => {
88
+ const depthA = a.split('/').length;
89
+ const depthB = b.split('/').length;
90
+ if (depthA !== depthB) return depthA - depthB;
91
+ return a.localeCompare(b);
92
+ });
93
+
94
+ return gltfCandidates[0]!;
95
+ }
96
+
97
+ /**
98
+ * Resolves a relative path given a base directory.
99
+ */
100
+ function resolveRelativePath(baseDir: string, relativePath: string): string {
101
+ // Strip leading ./
102
+ let cleanRel = relativePath.replace(/\\/g, '/').replace(/^\.\//, '');
103
+
104
+ if (!baseDir || baseDir === '.') {
105
+ return cleanRel;
106
+ }
107
+
108
+ const parts = (baseDir + '/' + cleanRel).split('/');
109
+ const resolvedParts: string[] = [];
110
+
111
+ for (const part of parts) {
112
+ if (!part || part === '.') continue;
113
+ if (part === '..') {
114
+ resolvedParts.pop();
115
+ } else {
116
+ resolvedParts.push(part);
117
+ }
118
+ }
119
+
120
+ return resolvedParts.join('/');
121
+ }
122
+
123
+ /**
124
+ * Creates an external resolver callback backed by an in-memory ZIP archive file map.
125
+ *
126
+ * @param fileMap The unpacked files map.
127
+ * @param gltfPath The path to the primary glTF file used as reference for relative paths.
128
+ * @returns An async resolver function matching the glTF loader resolver interface.
129
+ */
130
+ export function createZipResolver(
131
+ fileMap: Map<string, Uint8Array>,
132
+ gltfPath: string
133
+ ): (uri: string) => Promise<Uint8Array> {
134
+ const lastSlash = gltfPath.lastIndexOf('/');
135
+ const baseDir = lastSlash >= 0 ? gltfPath.substring(0, lastSlash) : '';
136
+
137
+ return async (uri: string): Promise<Uint8Array> => {
138
+ // 1. Decode URL encoded characters (e.g. %20 -> space)
139
+ const decodedUri = decodeURIComponent(uri.trim());
140
+
141
+ // 2. Resolve relative to baseDir
142
+ const resolvedPath = resolveRelativePath(baseDir, decodedUri);
143
+
144
+ // 3. Exact match lookup
145
+ let match = fileMap.get(resolvedPath);
146
+ if (match) return match;
147
+
148
+ // 4. Fallback: try raw decodedUri directly without baseDir
149
+ match = fileMap.get(decodedUri);
150
+ if (match) return match;
151
+
152
+ // 5. Fallback: case-insensitive search or filename-only match
153
+ const lowerResolved = resolvedPath.toLowerCase();
154
+ const fileName = decodedUri.split('/').pop()?.toLowerCase();
155
+
156
+ for (const [key, data] of fileMap.entries()) {
157
+ const keyLower = key.toLowerCase();
158
+ if (keyLower === lowerResolved) {
159
+ return data;
160
+ }
161
+ if (fileName && keyLower.endsWith('/' + fileName)) {
162
+ return data;
163
+ }
164
+ if (fileName && keyLower === fileName) {
165
+ return data;
166
+ }
167
+ }
168
+
169
+ throw new Error(
170
+ `[GltfZipResolver] File not found in ZIP archive: "${uri}" (resolved: "${resolvedPath}")`
171
+ );
172
+ };
173
+ }