@needle-tools/engine 5.1.0-experimental.03e8105 → 5.1.0-experimental.08fa2ef
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/dist/{needle-engine.bundle-DF6ovbwD.min.js → needle-engine.bundle-6jp9Udrr.min.js} +2 -2
- package/dist/{needle-engine.bundle-BNqUjnSQ.js → needle-engine.bundle-CB0g67az.js} +9 -9
- package/dist/{needle-engine.bundle-Bt8ULD7E.umd.cjs → needle-engine.bundle-D5db5ZP1.umd.cjs} +3 -3
- package/dist/needle-engine.d.ts +6 -6
- package/dist/needle-engine.js +368 -368
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/lib/engine/api.d.ts +1 -1
- package/lib/engine/api.js +1 -1
- package/lib/engine/api.js.map +1 -1
- package/lib/engine/engine_init.js +2 -2
- package/lib/engine/engine_init.js.map +1 -1
- package/lib/engine/engine_license.d.ts +7 -7
- package/lib/engine/engine_license.js +70 -70
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_networking_blob.js +3 -3
- package/lib/engine/engine_networking_blob.js.map +1 -1
- package/lib/engine/engine_utils_qrcode.js +2 -2
- package/lib/engine/engine_utils_qrcode.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
- package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.js +2 -2
- package/lib/engine/webcomponents/needle-engine.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
- package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
- package/lib/engine/xr/TempXRContext.js +2 -2
- package/lib/engine/xr/TempXRContext.js.map +1 -1
- package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
- package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
- package/package.json +1 -1
- package/plugins/common/license.js +4 -4
- package/plugins/dts-generator/dts.codegen.js +334 -0
- package/plugins/dts-generator/dts.scan.js +99 -0
- package/plugins/dts-generator/dts.writer.js +59 -0
- package/plugins/dts-generator/glb.discovery.js +279 -0
- package/plugins/dts-generator/glb.extractor.js +215 -0
- package/plugins/dts-generator/glb.reader.js +167 -0
- package/plugins/dts-generator/index.js +36 -0
- package/plugins/dts-generator/manifest.types.js +174 -0
- package/plugins/gltf-packer.mjs +1 -0
- package/plugins/vite/license.js +5 -9
- package/src/engine/api.ts +1 -1
- package/src/engine/engine_init.ts +2 -2
- package/src/engine/engine_license.ts +68 -68
- package/src/engine/engine_networking_blob.ts +3 -3
- package/src/engine/engine_utils_qrcode.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
- package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
- package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
- package/src/engine/webcomponents/needle-engine.ts +2 -2
- package/src/engine/xr/TempXRContext.ts +2 -2
- package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* GLB / glTF file discovery.
|
|
4
|
+
*
|
|
5
|
+
* Resolves which scene files to process — either from the project's entrypoints
|
|
6
|
+
* (index.html `<needle-engine src>` or gen.js push calls) or by recursively
|
|
7
|
+
* walking the assets directory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
11
|
+
import { join, basename, extname } from 'path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generic URL path segments that carry no useful identity — fall back to
|
|
15
|
+
* the previous segment when the last segment matches one of these.
|
|
16
|
+
*/
|
|
17
|
+
const GENERIC_SEGMENTS = new Set(["file", "index", "scene", "assets", "glb", "gltf", "model", "download"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Derive a short, human-friendly identifier from a GLB URL or local path.
|
|
21
|
+
*
|
|
22
|
+
* Rules (in priority order):
|
|
23
|
+
* 1. If `contentDispositionFilename` is provided → strip extension, use it.
|
|
24
|
+
* 2. Remote URL → walk path segments right-to-left, skip generic ones,
|
|
25
|
+
* strip extension from first useful segment.
|
|
26
|
+
* 3. Local path → basename without extension.
|
|
27
|
+
*
|
|
28
|
+
* Result is identifier-safe: non-alphanumeric chars replaced with `_`,
|
|
29
|
+
* leading digits prefixed with `_`.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} pathOrUrl
|
|
32
|
+
* @param {string | null} [contentDispositionFilename]
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function glbFriendlyName(pathOrUrl, contentDispositionFilename) {
|
|
36
|
+
let raw = "";
|
|
37
|
+
|
|
38
|
+
if (contentDispositionFilename) {
|
|
39
|
+
raw = basename(contentDispositionFilename, extname(contentDispositionFilename));
|
|
40
|
+
} else if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
|
41
|
+
try {
|
|
42
|
+
const segments = new URL(pathOrUrl).pathname.split("/").filter(Boolean);
|
|
43
|
+
// Walk right-to-left, skip generic segments
|
|
44
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
45
|
+
const seg = segments[i];
|
|
46
|
+
const withoutExt = seg.replace(/\.(glb|gltf)$/i, "");
|
|
47
|
+
if (!GENERIC_SEGMENTS.has(withoutExt.toLowerCase())) {
|
|
48
|
+
raw = withoutExt;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!raw) raw = segments[segments.length - 1] ?? "scene";
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
raw = "scene";
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
raw = basename(pathOrUrl, extname(pathOrUrl));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Make identifier-safe
|
|
61
|
+
let id = raw.replace(/[^a-zA-Z0-9]/g, "_");
|
|
62
|
+
if (/^\d/.test(id)) id = "_" + id;
|
|
63
|
+
return id || "scene";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Source file extensions that may contain `<needle-engine src="...">` markup. */
|
|
67
|
+
const SOURCE_EXTENSIONS = /\.(html|svelte|tsx|jsx|vue)$/i;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse `<needle-engine src="...">` from HTML.
|
|
71
|
+
* Handles both single strings and JSON arrays.
|
|
72
|
+
* @param {string} html
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
function parseSrcAttribute(html) {
|
|
76
|
+
const re = /<needle-engine[\s\S]*?\ssrc=["']([^"']+)["']/gi;
|
|
77
|
+
/** @type {string[]} */
|
|
78
|
+
const out = [];
|
|
79
|
+
let m;
|
|
80
|
+
while ((m = re.exec(html)) !== null) {
|
|
81
|
+
const val = m[1].trim();
|
|
82
|
+
if (val.startsWith("[")) {
|
|
83
|
+
try {
|
|
84
|
+
const arr = JSON.parse(val);
|
|
85
|
+
if (Array.isArray(arr)) out.push(...arr.filter(v => typeof v === "string"));
|
|
86
|
+
} catch (_e) { /* malformed JSON, skip */ }
|
|
87
|
+
} else {
|
|
88
|
+
out.push(val);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Parse `needle_exported_files.push("path/to/file.glb")` lines from gen.js.
|
|
96
|
+
* @param {string} src
|
|
97
|
+
* @returns {string[]}
|
|
98
|
+
*/
|
|
99
|
+
function parseGenJs(src) {
|
|
100
|
+
const re = /needle_exported_files\.push\(["']([^"']+\.(?:glb|gltf))["']\)/gi;
|
|
101
|
+
/** @type {string[]} */
|
|
102
|
+
const out = [];
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(src)) !== null) {
|
|
105
|
+
out.push(m[1]);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve a list of (possibly relative) GLB path strings to absolute paths.
|
|
112
|
+
* Remote URLs (http/https) are passed through as-is.
|
|
113
|
+
* The `key` field preserves the original src value (relative path or URL)
|
|
114
|
+
* for use as the SceneMap key in the generated .d.ts.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<{glbPath: string, sourceFile: string | null}>} pathEntries
|
|
117
|
+
* @param {string} projectRoot
|
|
118
|
+
* @param {string} assetsDir
|
|
119
|
+
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}>}
|
|
120
|
+
*/
|
|
121
|
+
function resolveGlbPaths(pathEntries, projectRoot, assetsDir) {
|
|
122
|
+
/** @type {Map<string, {path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}>} */
|
|
123
|
+
const byKey = new Map();
|
|
124
|
+
for (const { glbPath: p, sourceFile } of pathEntries) {
|
|
125
|
+
if (p.startsWith("http://") || p.startsWith("https://")) {
|
|
126
|
+
const type = p.toLowerCase().endsWith(".gltf") ? "gltf" : "glb";
|
|
127
|
+
const existing = byKey.get(p);
|
|
128
|
+
if (existing) {
|
|
129
|
+
if (sourceFile) existing.sourceFiles.push(sourceFile);
|
|
130
|
+
} else {
|
|
131
|
+
byKey.set(p, { path: p, type, remote: true, key: p, sourceFiles: sourceFile ? [sourceFile] : [] });
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const clean = p.replace(/^\.\//, "").replace(/^\//, "");
|
|
136
|
+
const candidates = [
|
|
137
|
+
join(projectRoot, clean),
|
|
138
|
+
join(assetsDir, clean.replace(/^assets\//, "")),
|
|
139
|
+
];
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
if (existsSync(candidate)) {
|
|
142
|
+
const type = candidate.toLowerCase().endsWith(".gltf") ? "gltf" : "glb";
|
|
143
|
+
const existing = byKey.get(clean);
|
|
144
|
+
if (existing) {
|
|
145
|
+
if (sourceFile) existing.sourceFiles.push(sourceFile);
|
|
146
|
+
} else {
|
|
147
|
+
byKey.set(clean, { path: candidate, type, key: clean, sourceFiles: sourceFile ? [sourceFile] : [] });
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return Array.from(byKey.values());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Walk `src/` (or the project root) for source files that may contain
|
|
158
|
+
* `<needle-engine src="...">` and return GLB path + source file pairs.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} projectRoot
|
|
161
|
+
* @returns {Array<{glbPath: string, sourceFile: string}>}
|
|
162
|
+
*/
|
|
163
|
+
function scanSourceFilesForGlbs(projectRoot) {
|
|
164
|
+
/** @type {Array<{glbPath: string, sourceFile: string}>} */
|
|
165
|
+
const out = [];
|
|
166
|
+
const srcDir = join(projectRoot, "src");
|
|
167
|
+
const searchRoot = existsSync(srcDir) ? srcDir : projectRoot;
|
|
168
|
+
|
|
169
|
+
/** @param {string} dir */
|
|
170
|
+
function walk(dir) {
|
|
171
|
+
let entries;
|
|
172
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch (_e) { return; }
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
const fullPath = join(dir, entry.name);
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
// Skip node_modules and hidden dirs
|
|
177
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
|
178
|
+
walk(fullPath);
|
|
179
|
+
} else if (SOURCE_EXTENSIONS.test(entry.name)) {
|
|
180
|
+
try {
|
|
181
|
+
const content = readFileSync(fullPath, "utf8");
|
|
182
|
+
const relPath = fullPath.replace(projectRoot + "/", "").replace(projectRoot + "\\", "");
|
|
183
|
+
for (const glbPath of parseSrcAttribute(content)) {
|
|
184
|
+
out.push({ glbPath, sourceFile: relPath });
|
|
185
|
+
}
|
|
186
|
+
} catch (_e) { /* ignore unreadable files */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
walk(searchRoot);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the entrypoint GLB file paths for a project.
|
|
197
|
+
*
|
|
198
|
+
* Sources (all merged, deduplicated by key):
|
|
199
|
+
* 1. `<needle-engine src="...">` in `index.html`
|
|
200
|
+
* 2. `needle_exported_files.push("...")` lines in `{codegenDir}/gen.js`
|
|
201
|
+
* 3. `<needle-engine src="...">` in any `.svelte`, `.tsx`, `.jsx`, `.vue`, `.html` under `src/`
|
|
202
|
+
*
|
|
203
|
+
* Returns `null` if nothing found — caller should fall back to `collectSceneFiles`.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} projectRoot Absolute path to the project root
|
|
206
|
+
* @param {string} assetsDir Absolute path to the assets directory
|
|
207
|
+
* @param {string} [codegenDir] Absolute path to the codegen directory
|
|
208
|
+
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}> | null}
|
|
209
|
+
*/
|
|
210
|
+
export function resolveEntrypointGlbs(projectRoot, assetsDir, codegenDir) {
|
|
211
|
+
/** @type {Array<{glbPath: string, sourceFile: string | null}>} */
|
|
212
|
+
const allEntries = [];
|
|
213
|
+
|
|
214
|
+
// 1. index.html
|
|
215
|
+
const htmlPath = join(projectRoot, "index.html");
|
|
216
|
+
if (existsSync(htmlPath)) {
|
|
217
|
+
try {
|
|
218
|
+
for (const glbPath of parseSrcAttribute(readFileSync(htmlPath, "utf8"))) {
|
|
219
|
+
allEntries.push({ glbPath, sourceFile: "index.html" });
|
|
220
|
+
}
|
|
221
|
+
} catch (_e) { /* ignore */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 2. gen.js
|
|
225
|
+
const genDir = codegenDir ?? join(projectRoot, "src", "generated");
|
|
226
|
+
const genPath = join(genDir, "gen.js");
|
|
227
|
+
if (existsSync(genPath)) {
|
|
228
|
+
try {
|
|
229
|
+
const relGenPath = genPath.replace(projectRoot + "/", "").replace(projectRoot + "\\", "");
|
|
230
|
+
for (const glbPath of parseGenJs(readFileSync(genPath, "utf8"))) {
|
|
231
|
+
allEntries.push({ glbPath, sourceFile: relGenPath });
|
|
232
|
+
}
|
|
233
|
+
} catch (_e) { /* ignore */ }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 3. Source files (SvelteKit, React, Vue, etc.)
|
|
237
|
+
allEntries.push(...scanSourceFilesForGlbs(projectRoot));
|
|
238
|
+
|
|
239
|
+
if (allEntries.length === 0) return null;
|
|
240
|
+
|
|
241
|
+
const resolved = resolveGlbPaths(allEntries, projectRoot, assetsDir);
|
|
242
|
+
return resolved.length > 0 ? resolved : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Recursively collect all scene GLB/glTF files in a directory.
|
|
247
|
+
* Skips LOD and image sub-glbs that aren't scene roots.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} assetsDir
|
|
250
|
+
* @returns {Array<{path: string, type: "glb"|"gltf"}>}
|
|
251
|
+
*/
|
|
252
|
+
export function collectSceneFiles(assetsDir) {
|
|
253
|
+
if (!existsSync(assetsDir)) return [];
|
|
254
|
+
|
|
255
|
+
/** @type {Array<{path: string, type: "glb"|"gltf"}>} */
|
|
256
|
+
const out = [];
|
|
257
|
+
|
|
258
|
+
/** @param {string} dir */
|
|
259
|
+
function walk(dir) {
|
|
260
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
261
|
+
const fullPath = join(dir, entry.name);
|
|
262
|
+
if (entry.isDirectory()) {
|
|
263
|
+
walk(fullPath);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (/^image_\d+_.*\.glb$/i.test(entry.name)) continue;
|
|
267
|
+
if (/^mesh_lod_\d+_.*\.glb$/i.test(entry.name)) continue;
|
|
268
|
+
|
|
269
|
+
if (/\.glb$/i.test(entry.name)) {
|
|
270
|
+
out.push({ path: fullPath, type: "glb" });
|
|
271
|
+
} else if (/\.gltf$/i.test(entry.name)) {
|
|
272
|
+
out.push({ path: fullPath, type: "gltf" });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
walk(assetsDir);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* glTF node tree walker and NEEDLE_components extractor.
|
|
4
|
+
*
|
|
5
|
+
* Walks the parsed glTF JSON, sanitizes node names to match Three.js runtime
|
|
6
|
+
* behaviour, and extracts component data from the NEEDLE_components extension.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const NEEDLE_COMPONENTS_EXTENSION = "NEEDLE_components";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Replicate Three.js GLTFLoader's sanitizeNodeName + createUniqueName logic.
|
|
13
|
+
* - Whitespace → `_`
|
|
14
|
+
* - Reserved chars `[ ] . : /` → removed
|
|
15
|
+
* - Duplicate names get `_1`, `_2`, … suffix
|
|
16
|
+
*
|
|
17
|
+
* @param {string} rawName
|
|
18
|
+
* @param {Record<string, number>} namesUsed Mutated in-place — pass the same object for all nodes in a GLB
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeNodeName(rawName, namesUsed) {
|
|
22
|
+
const sanitized = rawName.replace(/\s/g, "_").replace(/[\[\].:\/]/g, "");
|
|
23
|
+
if (sanitized in namesUsed) {
|
|
24
|
+
return sanitized + "_" + (++namesUsed[sanitized]);
|
|
25
|
+
}
|
|
26
|
+
namesUsed[sanitized] = 0;
|
|
27
|
+
return sanitized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Infer the Three.js runtime type of a glTF node from its JSON properties.
|
|
32
|
+
* - `mesh` present with skinning → `import("three").SkinnedMesh`
|
|
33
|
+
* - `mesh` present → `import("three").Mesh`
|
|
34
|
+
* - `camera` present, perspective → `import("three").PerspectiveCamera`
|
|
35
|
+
* - `camera` present, orthographic→ `import("three").OrthographicCamera`
|
|
36
|
+
* - KHR_lights_punctual → `import("three").Light`
|
|
37
|
+
* - otherwise → `import("three").Object3D`
|
|
38
|
+
*
|
|
39
|
+
* @param {Record<string, unknown>} node
|
|
40
|
+
* @param {Array<Record<string, unknown>>} cameras Raw glTF cameras array
|
|
41
|
+
* @param {Array<Record<string, unknown>>} meshes Raw glTF meshes array
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function inferNodeThreeType(node, cameras, meshes) {
|
|
45
|
+
if ("mesh" in node) {
|
|
46
|
+
const hasSkin = "skin" in node;
|
|
47
|
+
if (!hasSkin) {
|
|
48
|
+
const meshIdx = /** @type {number} */ (node.mesh);
|
|
49
|
+
const mesh = meshes[meshIdx];
|
|
50
|
+
const primitives = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(mesh?.primitives) ? mesh.primitives : []);
|
|
51
|
+
const isSkinned = primitives.some(p => {
|
|
52
|
+
const attrs = /** @type {Record<string, unknown>} */ (p?.attributes ?? {});
|
|
53
|
+
return "WEIGHTS_0" in attrs;
|
|
54
|
+
});
|
|
55
|
+
if (isSkinned) return `import("three").SkinnedMesh`;
|
|
56
|
+
} else {
|
|
57
|
+
return `import("three").SkinnedMesh`;
|
|
58
|
+
}
|
|
59
|
+
return `import("three").Mesh`;
|
|
60
|
+
}
|
|
61
|
+
if ("camera" in node) {
|
|
62
|
+
const camIdx = /** @type {number} */ (node.camera);
|
|
63
|
+
const cam = cameras[camIdx];
|
|
64
|
+
if (cam && typeof cam === "object") {
|
|
65
|
+
if (cam.type === "perspective") return `import("three").PerspectiveCamera`;
|
|
66
|
+
if (cam.type === "orthographic") return `import("three").OrthographicCamera`;
|
|
67
|
+
}
|
|
68
|
+
return `import("three").Camera`;
|
|
69
|
+
}
|
|
70
|
+
if (node.extensions && /** @type {any} */ (node.extensions)["KHR_lights_punctual"] != null) return `import("three").Light`;
|
|
71
|
+
return `import("three").Object3D`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Infer a TypeScript type string from a raw JSON value.
|
|
76
|
+
* Only primitives are typed precisely; everything else → `unknown`.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} value
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function inferTsType(value) {
|
|
82
|
+
if (value === null || value === undefined) return "unknown";
|
|
83
|
+
switch (typeof value) {
|
|
84
|
+
case "number": return "number";
|
|
85
|
+
case "string": return "string";
|
|
86
|
+
case "boolean": return "boolean";
|
|
87
|
+
default: return "unknown";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a map of nodeIndex → sanitized name and nodeIndex → parent index
|
|
93
|
+
* from the glTF node tree, then compute the full path for each node.
|
|
94
|
+
*
|
|
95
|
+
* @param {Array<Record<string, unknown>>} nodes Raw glTF nodes array
|
|
96
|
+
* @param {Record<string, number>} namesUsed Already-used name registry (shared with extraction pass)
|
|
97
|
+
* @returns {{ nameMap: Map<number, string>, pathMap: Map<number, string> }}
|
|
98
|
+
*/
|
|
99
|
+
function buildNodePaths(nodes, namesUsed) {
|
|
100
|
+
/** @type {Map<number, string>} nodeIndex → sanitized name */
|
|
101
|
+
const nameMap = new Map();
|
|
102
|
+
/** @type {Map<number, number>} nodeIndex → parent nodeIndex */
|
|
103
|
+
const parentMap = new Map();
|
|
104
|
+
|
|
105
|
+
// First pass: sanitize all names
|
|
106
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
107
|
+
const node = nodes[i];
|
|
108
|
+
if (!node || typeof node !== "object") continue;
|
|
109
|
+
const rawName = typeof node.name === "string" ? node.name : "";
|
|
110
|
+
if (!rawName) continue;
|
|
111
|
+
nameMap.set(i, sanitizeNodeName(rawName, namesUsed));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Second pass: build parent map from children arrays
|
|
115
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
116
|
+
const node = nodes[i];
|
|
117
|
+
if (!node || typeof node !== "object") continue;
|
|
118
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
119
|
+
for (const childIdx of children) {
|
|
120
|
+
parentMap.set(childIdx, i);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Third pass: compute full path per node by walking up the parent chain
|
|
125
|
+
/** @type {Map<number, string>} nodeIndex → full path string */
|
|
126
|
+
const pathMap = new Map();
|
|
127
|
+
for (const [idx] of nameMap) {
|
|
128
|
+
const parts = [];
|
|
129
|
+
/** @type {number | undefined} */
|
|
130
|
+
let cur = idx;
|
|
131
|
+
while (cur !== undefined && nameMap.has(cur)) {
|
|
132
|
+
parts.unshift(/** @type {string} */ (nameMap.get(cur)));
|
|
133
|
+
cur = parentMap.get(cur);
|
|
134
|
+
}
|
|
135
|
+
pathMap.set(idx, parts.join("/"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { nameMap, pathMap };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Walk the glTF JSON and collect every node name + any NEEDLE_components extension blocks.
|
|
143
|
+
* Node names are sanitized and de-duplicated to match Three.js GLTFLoader behaviour.
|
|
144
|
+
*
|
|
145
|
+
* Nodes without NEEDLE_components are returned with an empty componentName so that
|
|
146
|
+
* callers can decide whether to emit them.
|
|
147
|
+
*
|
|
148
|
+
* @param {Record<string, unknown>} json Parsed glTF JSON
|
|
149
|
+
* @returns {Array<{nodeName: string, nodePath: string, componentName: string, fields: Record<string, unknown>, nodeThreeType: string}>}
|
|
150
|
+
*/
|
|
151
|
+
export function extractComponentBindings(json) {
|
|
152
|
+
const nodes = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.nodes) ? json.nodes : []);
|
|
153
|
+
const cameras = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.cameras) ? json.cameras : []);
|
|
154
|
+
const meshes = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.meshes) ? json.meshes : []);
|
|
155
|
+
|
|
156
|
+
// Collect scene root node indices — these map to THREE.Scene at runtime
|
|
157
|
+
const scenes = /** @type {Array<Record<string, unknown>>} */ (Array.isArray(json.scenes) ? json.scenes : []);
|
|
158
|
+
/** @type {Set<number>} */
|
|
159
|
+
const sceneRootNodeIndices = new Set();
|
|
160
|
+
for (const scene of scenes) {
|
|
161
|
+
const sceneNodes = Array.isArray(scene.nodes) ? /** @type {number[]} */ (scene.nodes) : [];
|
|
162
|
+
for (const idx of sceneNodes) sceneRootNodeIndices.add(idx);
|
|
163
|
+
}
|
|
164
|
+
/** @type {Array<{nodeName: string, nodePath: string, componentName: string, fields: Record<string, unknown>, nodeThreeType: string}>} */
|
|
165
|
+
const results = [];
|
|
166
|
+
/** @type {Record<string, number>} */
|
|
167
|
+
const namesUsed = {};
|
|
168
|
+
|
|
169
|
+
const { nameMap, pathMap } = buildNodePaths(nodes, namesUsed);
|
|
170
|
+
|
|
171
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
172
|
+
const node = nodes[i];
|
|
173
|
+
if (!node || typeof node !== "object") continue;
|
|
174
|
+
|
|
175
|
+
const nodeName = nameMap.get(i);
|
|
176
|
+
if (!nodeName) continue;
|
|
177
|
+
|
|
178
|
+
const nodePath = pathMap.get(i) ?? nodeName;
|
|
179
|
+
const nodeThreeType = sceneRootNodeIndices.has(i)
|
|
180
|
+
? `import("three").Scene`
|
|
181
|
+
: inferNodeThreeType(/** @type {Record<string, unknown>} */ (node), cameras, meshes);
|
|
182
|
+
const ext = node.extensions?.[NEEDLE_COMPONENTS_EXTENSION];
|
|
183
|
+
|
|
184
|
+
if (!ext || typeof ext !== "object") {
|
|
185
|
+
results.push({ nodeName, nodePath, componentName: "", fields: {}, nodeThreeType });
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const lists = [
|
|
190
|
+
...(Array.isArray(ext.components) ? ext.components : []),
|
|
191
|
+
...(Array.isArray(ext.builtin_components) ? ext.builtin_components : []),
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
const internalKeys = new Set([
|
|
195
|
+
"name", "type", "guid", "gameObject", "enabled",
|
|
196
|
+
"didAwake", "didStart", "transformHandle", "destroyCancellationToken",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
for (const comp of lists) {
|
|
200
|
+
if (!comp || typeof comp !== "object") continue;
|
|
201
|
+
const componentName = typeof comp.name === "string" ? comp.name.trim() : "";
|
|
202
|
+
if (!componentName) continue;
|
|
203
|
+
|
|
204
|
+
/** @type {Record<string, unknown>} */
|
|
205
|
+
const fields = {};
|
|
206
|
+
for (const [k, v] of Object.entries(comp)) {
|
|
207
|
+
if (!internalKeys.has(k)) fields[k] = v;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
results.push({ nodeName, nodePath, componentName, fields, nodeThreeType });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return results;
|
|
215
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* GLB / glTF JSON chunk readers — local files and remote URLs.
|
|
4
|
+
*
|
|
5
|
+
* All functions return the parsed glTF JSON object or null on failure.
|
|
6
|
+
* Remote fetches use HTTP Range requests and cache by Last-Modified header.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { openSync, readSync, closeSync, readFileSync } from 'fs';
|
|
10
|
+
import { needleLog } from '../vite/logging.js';
|
|
11
|
+
|
|
12
|
+
const PLUGIN = "needle:dts-generator";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read the JSON chunk from a binary GLB file without loading the binary blob.
|
|
16
|
+
* @param {string} filePath
|
|
17
|
+
* @returns {Record<string, unknown> | null}
|
|
18
|
+
*/
|
|
19
|
+
export function readGlbJsonChunk(filePath) {
|
|
20
|
+
const fd = openSync(filePath, "r");
|
|
21
|
+
try {
|
|
22
|
+
const header = Buffer.allocUnsafe(20);
|
|
23
|
+
const bytesRead = readSync(fd, header, 0, header.length, 0);
|
|
24
|
+
if (bytesRead < 20) return null;
|
|
25
|
+
|
|
26
|
+
const magic = header.readUInt32LE(0);
|
|
27
|
+
const version = header.readUInt32LE(4);
|
|
28
|
+
const chunkLength = header.readUInt32LE(12);
|
|
29
|
+
const chunkType = header.readUInt32LE(16);
|
|
30
|
+
|
|
31
|
+
if (magic !== 0x46546c67 || version !== 2) return null;
|
|
32
|
+
if (chunkType !== 0x4E4F534A) return null; // not JSON chunk
|
|
33
|
+
|
|
34
|
+
const jsonBuffer = Buffer.allocUnsafe(chunkLength);
|
|
35
|
+
const jsonBytesRead = readSync(fd, jsonBuffer, 0, chunkLength, 20);
|
|
36
|
+
if (jsonBytesRead < chunkLength) return null;
|
|
37
|
+
|
|
38
|
+
return JSON.parse(jsonBuffer.toString("utf8").replace(/\u0000+$/g, ""));
|
|
39
|
+
} catch (_e) {
|
|
40
|
+
return null;
|
|
41
|
+
} finally {
|
|
42
|
+
closeSync(fd);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} filePath
|
|
48
|
+
* @returns {Record<string, unknown> | null}
|
|
49
|
+
*/
|
|
50
|
+
export function readGltfJsonFile(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse the filename from a `Content-Disposition` header value.
|
|
60
|
+
* Handles `filename="foo.glb"` and `filename*=UTF-8''foo.glb` forms.
|
|
61
|
+
* Returns null if not present or not parseable.
|
|
62
|
+
* @param {string | null} header
|
|
63
|
+
* @returns {string | null}
|
|
64
|
+
*/
|
|
65
|
+
export function parseContentDispositionFilename(header) {
|
|
66
|
+
if (!header) return null;
|
|
67
|
+
// RFC 5987 extended form: filename*=UTF-8''foo.glb
|
|
68
|
+
const extMatch = header.match(/filename\*\s*=\s*(?:[^']*'')?([^;]+)/i);
|
|
69
|
+
if (extMatch) {
|
|
70
|
+
try { return decodeURIComponent(extMatch[1].trim()); } catch (_e) { /* fall through */ }
|
|
71
|
+
}
|
|
72
|
+
// Plain form: filename="foo.glb" or filename=foo.glb
|
|
73
|
+
const plainMatch = header.match(/filename\s*=\s*"?([^";]+)"?/i);
|
|
74
|
+
if (plainMatch) return plainMatch[1].trim();
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @type {Map<string, { lastModified: string, json: Record<string, unknown>, filename: string | null }>} */
|
|
79
|
+
const _remoteGlbCache = new Map();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Fetch the JSON chunk of a remote GLB.
|
|
83
|
+
* Tries an initial Range request for the header bytes; if the server already returned
|
|
84
|
+
* enough data (200 + full body, or 206 with sufficient bytes) the JSON chunk is
|
|
85
|
+
* extracted directly. Otherwise a second Range request fetches the JSON chunk.
|
|
86
|
+
* Uses `Last-Modified` header for caching — avoids re-parsing if unchanged.
|
|
87
|
+
* Also captures `Content-Disposition` filename for friendly key generation.
|
|
88
|
+
* Returns `null` on any network or parse error.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} url
|
|
91
|
+
* @returns {Promise<{ json: Record<string, unknown>, filename: string | null } | null>}
|
|
92
|
+
*/
|
|
93
|
+
export async function readRemoteGlbJsonChunk(url) {
|
|
94
|
+
try {
|
|
95
|
+
const cached = _remoteGlbCache.get(url);
|
|
96
|
+
|
|
97
|
+
const headerRes = await fetch(url, { headers: { Range: "bytes=0-19" } });
|
|
98
|
+
if (!headerRes.ok) {
|
|
99
|
+
needleLog(PLUGIN, `Remote GLB fetch failed (${headerRes.status}): ${url}`, "warn");
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const lastModified = headerRes.headers.get("Last-Modified") ?? "";
|
|
104
|
+
const filename = parseContentDispositionFilename(headerRes.headers.get("Content-Disposition"));
|
|
105
|
+
|
|
106
|
+
if (cached && lastModified && cached.lastModified === lastModified) {
|
|
107
|
+
// Prefer the filename we resolved during the full fetch (may have come from the JSON chunk
|
|
108
|
+
// response). Only fall back to the fresh header value if the cache has nothing.
|
|
109
|
+
return { json: cached.json, filename: cached.filename ?? filename };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const firstBytes = Buffer.from(await headerRes.arrayBuffer());
|
|
113
|
+
if (firstBytes.length < 20) return null;
|
|
114
|
+
|
|
115
|
+
const magic = firstBytes.readUInt32LE(0);
|
|
116
|
+
const version = firstBytes.readUInt32LE(4);
|
|
117
|
+
const chunkLength = firstBytes.readUInt32LE(12);
|
|
118
|
+
const chunkType = firstBytes.readUInt32LE(16);
|
|
119
|
+
|
|
120
|
+
if (magic !== 0x46546c67 || version !== 2) return null; // not a GLB
|
|
121
|
+
if (chunkType !== 0x4E4F534A) return null; // chunk0 not JSON
|
|
122
|
+
|
|
123
|
+
let jsonBytes;
|
|
124
|
+
let resolvedFilename = filename;
|
|
125
|
+
|
|
126
|
+
if (firstBytes.length >= 20 + chunkLength) {
|
|
127
|
+
// The first response already contained the full JSON chunk (server ignored Range or returned 200).
|
|
128
|
+
jsonBytes = firstBytes.slice(20, 20 + chunkLength);
|
|
129
|
+
} else {
|
|
130
|
+
// Try a second Range request for just the JSON chunk bytes.
|
|
131
|
+
const jsonEnd = 20 + chunkLength - 1;
|
|
132
|
+
const jsonRes = await fetch(url, { headers: { Range: `bytes=20-${jsonEnd}` } });
|
|
133
|
+
if (!jsonRes.ok) {
|
|
134
|
+
needleLog(PLUGIN, `Remote GLB JSON chunk fetch failed (${jsonRes.status}): ${url}`, "warn");
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const jsonResBytes = Buffer.from(await jsonRes.arrayBuffer());
|
|
138
|
+
// Server may return 200 + full body even for a Range request — slice out our window.
|
|
139
|
+
if (jsonResBytes.length >= 20 + chunkLength) {
|
|
140
|
+
jsonBytes = jsonResBytes.slice(20, 20 + chunkLength);
|
|
141
|
+
} else if (jsonResBytes.length >= chunkLength) {
|
|
142
|
+
jsonBytes = jsonResBytes.slice(0, chunkLength);
|
|
143
|
+
} else {
|
|
144
|
+
needleLog(PLUGIN, `Remote GLB unexpected response (${jsonRes.status}, ${jsonResBytes.length} bytes, expected ${chunkLength}): ${url}`, "warn");
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const filenameFromJsonRes = parseContentDispositionFilename(jsonRes.headers.get("Content-Disposition"));
|
|
149
|
+
resolvedFilename = filename ?? filenameFromJsonRes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (resolvedFilename === null) {
|
|
153
|
+
try {
|
|
154
|
+
const headRes = await fetch(url, { method: "HEAD" });
|
|
155
|
+
resolvedFilename = parseContentDispositionFilename(headRes.headers.get("Content-Disposition"));
|
|
156
|
+
} catch (_e) { /* ignore — fall back to URL-based name */ }
|
|
157
|
+
}
|
|
158
|
+
const json = /** @type {Record<string, unknown>} */ (
|
|
159
|
+
JSON.parse(jsonBytes.toString("utf8").replace(/\u0000+$/g, ""))
|
|
160
|
+
);
|
|
161
|
+
_remoteGlbCache.set(url, { lastModified, json, filename: resolvedFilename });
|
|
162
|
+
return { json, filename: resolvedFilename };
|
|
163
|
+
} catch (e) {
|
|
164
|
+
needleLog(PLUGIN, `Failed to fetch remote GLB: ${url} — ${/** @type {any} */ (e)?.message ?? e}`, "warn");
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|