@needle-tools/engine 5.1.0-canary.db0c38f → 5.1.0-canary.deec6e4
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/.needle/generated/needle-bindings.gen.d.ts +5 -0
- package/CHANGELOG.md +34 -0
- package/components.needle.json +1 -1
- package/dist/{needle-engine.bundle-YnpzzOPL.min.js → needle-engine.bundle-1s2gOoKZ.min.js} +144 -144
- package/dist/{needle-engine.bundle-B29kieh0.js → needle-engine.bundle-CvtELXh0.js} +6650 -6584
- package/dist/{needle-engine.bundle-Dq0Ly8fW.umd.cjs → needle-engine.bundle-j4nGJXCs.umd.cjs} +138 -138
- package/dist/needle-engine.d.ts +101 -89
- package/dist/needle-engine.js +188 -186
- 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/debug/debug_spatial_console.d.ts +2 -0
- package/lib/engine/debug/debug_spatial_console.js +10 -7
- package/lib/engine/debug/debug_spatial_console.js.map +1 -1
- package/lib/engine/engine_addressables.d.ts +2 -0
- package/lib/engine/engine_addressables.js +6 -3
- package/lib/engine/engine_addressables.js.map +1 -1
- package/lib/engine/engine_context.d.ts +21 -20
- package/lib/engine/engine_context.js +25 -14
- package/lib/engine/engine_context.js.map +1 -1
- package/lib/engine/engine_init.js +15 -0
- package/lib/engine/engine_init.js.map +1 -1
- package/lib/engine/engine_license.d.ts +2 -0
- package/lib/engine/engine_license.js +14 -6
- package/lib/engine/engine_license.js.map +1 -1
- package/lib/engine/engine_lifecycle_functions_internal.js +5 -0
- package/lib/engine/engine_lifecycle_functions_internal.js.map +1 -1
- package/lib/engine/engine_pmrem.js +2 -2
- package/lib/engine/engine_pmrem.js.map +1 -1
- package/lib/engine/engine_scenedata.d.ts +13 -17
- package/lib/engine/engine_scenedata.js +56 -29
- package/lib/engine/engine_scenedata.js.map +1 -1
- package/lib/engine/engine_serialization_builtin_serializer.d.ts +10 -16
- package/lib/engine/engine_serialization_builtin_serializer.js +28 -41
- package/lib/engine/engine_serialization_builtin_serializer.js.map +1 -1
- package/lib/engine/engine_ssr.d.ts +2 -0
- package/lib/engine/engine_ssr.js +20 -0
- package/lib/engine/engine_ssr.js.map +1 -1
- package/lib/engine/engine_types.d.ts +2 -0
- package/lib/engine/engine_types.js.map +1 -1
- package/lib/engine/webcomponents/jsx.d.ts +51 -0
- package/lib/engine/webcomponents/logo-element.js.map +1 -1
- package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +2 -3
- package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
- package/lib/engine/webcomponents/needle-button.js.map +1 -1
- package/lib/engine/webcomponents/needle-engine.js.map +1 -1
- package/lib/engine-components/AnimatorController.d.ts +2 -0
- package/lib/engine-components/AnimatorController.js +4 -1
- package/lib/engine-components/AnimatorController.js.map +1 -1
- package/lib/engine-components/Light.d.ts +6 -8
- package/lib/engine-components/Light.js +40 -27
- package/lib/engine-components/Light.js.map +1 -1
- package/lib/engine-components/ReflectionProbe.js +2 -0
- package/lib/engine-components/ReflectionProbe.js.map +1 -1
- package/lib/engine-components/postprocessing/VolumeParameter.d.ts +2 -0
- package/lib/engine-components/postprocessing/VolumeParameter.js +4 -1
- package/lib/engine-components/postprocessing/VolumeParameter.js.map +1 -1
- package/lib/needle-engine.d.ts +2 -0
- package/lib/needle-engine.js +2 -0
- package/lib/needle-engine.js.map +1 -1
- package/package.json +3 -2
- package/plugins/dts-generator/dts.codegen.js +255 -50
- package/plugins/dts-generator/dts.scan.js +37 -9
- package/plugins/dts-generator/dts.writer.js +1 -1
- package/plugins/dts-generator/glb.discovery.js +140 -23
- package/plugins/dts-generator/glb.extractor.js +48 -8
- package/plugins/dts-generator/glb.reader.js +80 -27
- package/plugins/dts-generator/index.js +1 -1
- package/plugins/types/needle-bindings.d.ts +25 -14
- package/plugins/types/userconfig.d.ts +12 -0
- package/plugins/vite/asap.js +1 -1
- package/plugins/vite/dependency-watcher.d.ts +2 -2
- package/plugins/vite/dependency-watcher.js +3 -4
- package/plugins/vite/drop.d.ts +2 -2
- package/plugins/vite/drop.js +3 -4
- package/plugins/vite/dts-generator.d.ts +2 -2
- package/plugins/vite/dts-generator.js +43 -9
- package/plugins/vite/index.d.ts +9 -3
- package/plugins/vite/index.js +23 -10
- package/plugins/vite/meta.js +4 -2
- package/plugins/vite/poster.d.ts +2 -2
- package/plugins/vite/poster.js +3 -5
- package/plugins/vite/reload.d.ts +2 -2
- package/plugins/vite/reload.js +22 -22
- package/src/engine/api.ts +1 -1
- package/src/engine/debug/debug_spatial_console.ts +10 -7
- package/src/engine/engine_addressables.ts +6 -3
- package/src/engine/engine_context.ts +34 -20
- package/src/engine/engine_init.ts +14 -0
- package/src/engine/engine_license.ts +12 -10
- package/src/engine/engine_lifecycle_functions_internal.ts +7 -0
- package/src/engine/engine_pmrem.ts +3 -3
- package/src/engine/engine_scenedata.ts +53 -27
- package/src/engine/engine_serialization_builtin_serializer.ts +32 -43
- package/src/engine/engine_ssr.ts +29 -3
- package/src/engine/engine_types.ts +2 -0
- package/src/engine/webcomponents/jsx.d.ts +51 -0
- package/src/engine/webcomponents/logo-element.ts +1 -0
- package/src/engine/webcomponents/needle menu/needle-menu.ts +2 -1
- package/src/engine/webcomponents/needle-button.ts +1 -0
- package/src/engine/webcomponents/needle-engine.ts +1 -0
- package/src/engine-components/AnimatorController.ts +4 -1
- package/src/engine-components/Light.ts +40 -26
- package/src/engine-components/ReflectionProbe.ts +2 -0
- package/src/engine-components/postprocessing/VolumeParameter.ts +4 -1
- package/src/needle-engine.ts +3 -0
|
@@ -8,7 +8,63 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
11
|
-
import { join,
|
|
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;
|
|
12
68
|
|
|
13
69
|
/**
|
|
14
70
|
* Parse `<needle-engine src="...">` from HTML.
|
|
@@ -17,7 +73,7 @@ import { join, resolve } from 'path';
|
|
|
17
73
|
* @returns {string[]}
|
|
18
74
|
*/
|
|
19
75
|
function parseSrcAttribute(html) {
|
|
20
|
-
const re = /<needle-engine[
|
|
76
|
+
const re = /<needle-engine[\s\S]*?\ssrc=["']([^"']+)["']/gi;
|
|
21
77
|
/** @type {string[]} */
|
|
22
78
|
const out = [];
|
|
23
79
|
let m;
|
|
@@ -54,19 +110,26 @@ function parseGenJs(src) {
|
|
|
54
110
|
/**
|
|
55
111
|
* Resolve a list of (possibly relative) GLB path strings to absolute paths.
|
|
56
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.
|
|
57
115
|
*
|
|
58
|
-
* @param {string
|
|
116
|
+
* @param {Array<{glbPath: string, sourceFile: string | null}>} pathEntries
|
|
59
117
|
* @param {string} projectRoot
|
|
60
118
|
* @param {string} assetsDir
|
|
61
|
-
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean}>}
|
|
119
|
+
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}>}
|
|
62
120
|
*/
|
|
63
|
-
function resolveGlbPaths(
|
|
64
|
-
/** @type {
|
|
65
|
-
const
|
|
66
|
-
for (const p of
|
|
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) {
|
|
67
125
|
if (p.startsWith("http://") || p.startsWith("https://")) {
|
|
68
126
|
const type = p.toLowerCase().endsWith(".gltf") ? "gltf" : "glb";
|
|
69
|
-
|
|
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
|
+
}
|
|
70
133
|
continue;
|
|
71
134
|
}
|
|
72
135
|
const clean = p.replace(/^\.\//, "").replace(/^\//, "");
|
|
@@ -77,52 +140,106 @@ function resolveGlbPaths(paths, projectRoot, assetsDir) {
|
|
|
77
140
|
for (const candidate of candidates) {
|
|
78
141
|
if (existsSync(candidate)) {
|
|
79
142
|
const type = candidate.toLowerCase().endsWith(".gltf") ? "gltf" : "glb";
|
|
80
|
-
|
|
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
|
+
}
|
|
81
149
|
break;
|
|
82
150
|
}
|
|
83
151
|
}
|
|
84
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);
|
|
85
192
|
return out;
|
|
86
193
|
}
|
|
87
194
|
|
|
88
195
|
/**
|
|
89
196
|
* Resolve the entrypoint GLB file paths for a project.
|
|
90
197
|
*
|
|
91
|
-
*
|
|
92
|
-
* 1. `<needle-engine src="...">`
|
|
198
|
+
* Sources (all merged, deduplicated by key):
|
|
199
|
+
* 1. `<needle-engine src="...">` in `index.html`
|
|
93
200
|
* 2. `needle_exported_files.push("...")` lines in `{codegenDir}/gen.js`
|
|
94
|
-
* 3.
|
|
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`.
|
|
95
204
|
*
|
|
96
205
|
* @param {string} projectRoot Absolute path to the project root
|
|
97
206
|
* @param {string} assetsDir Absolute path to the assets directory
|
|
98
207
|
* @param {string} [codegenDir] Absolute path to the codegen directory
|
|
99
|
-
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean}> | null}
|
|
208
|
+
* @returns {Array<{path: string, type: "glb"|"gltf", remote?: boolean, key: string, sourceFiles: string[]}> | null}
|
|
100
209
|
*/
|
|
101
210
|
export function resolveEntrypointGlbs(projectRoot, assetsDir, codegenDir) {
|
|
211
|
+
/** @type {Array<{glbPath: string, sourceFile: string | null}>} */
|
|
212
|
+
const allEntries = [];
|
|
213
|
+
|
|
214
|
+
// 1. index.html
|
|
102
215
|
const htmlPath = join(projectRoot, "index.html");
|
|
103
216
|
if (existsSync(htmlPath)) {
|
|
104
217
|
try {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
const resolved = resolveGlbPaths(srcPaths, projectRoot, assetsDir);
|
|
108
|
-
if (resolved.length > 0) return resolved;
|
|
218
|
+
for (const glbPath of parseSrcAttribute(readFileSync(htmlPath, "utf8"))) {
|
|
219
|
+
allEntries.push({ glbPath, sourceFile: "index.html" });
|
|
109
220
|
}
|
|
110
221
|
} catch (_e) { /* ignore */ }
|
|
111
222
|
}
|
|
112
223
|
|
|
224
|
+
// 2. gen.js
|
|
113
225
|
const genDir = codegenDir ?? join(projectRoot, "src", "generated");
|
|
114
226
|
const genPath = join(genDir, "gen.js");
|
|
115
227
|
if (existsSync(genPath)) {
|
|
116
228
|
try {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (resolved.length > 0) return resolved;
|
|
229
|
+
const relGenPath = genPath.replace(projectRoot + "/", "").replace(projectRoot + "\\", "");
|
|
230
|
+
for (const glbPath of parseGenJs(readFileSync(genPath, "utf8"))) {
|
|
231
|
+
allEntries.push({ glbPath, sourceFile: relGenPath });
|
|
121
232
|
}
|
|
122
233
|
} catch (_e) { /* ignore */ }
|
|
123
234
|
}
|
|
124
235
|
|
|
125
|
-
|
|
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;
|
|
126
243
|
}
|
|
127
244
|
|
|
128
245
|
/**
|
|
@@ -29,17 +29,44 @@ export function sanitizeNodeName(rawName, namesUsed) {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Infer the Three.js runtime type of a glTF node from its JSON properties.
|
|
32
|
-
* - `mesh` present
|
|
33
|
-
* - `
|
|
34
|
-
* -
|
|
35
|
-
* -
|
|
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`
|
|
36
38
|
*
|
|
37
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
|
|
38
42
|
* @returns {string}
|
|
39
43
|
*/
|
|
40
|
-
export function inferNodeThreeType(node) {
|
|
41
|
-
if ("mesh" in node)
|
|
42
|
-
|
|
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
|
+
}
|
|
43
70
|
if (node.extensions && /** @type {any} */ (node.extensions)["KHR_lights_punctual"] != null) return `import("three").Light`;
|
|
44
71
|
return `import("three").Object3D`;
|
|
45
72
|
}
|
|
@@ -123,6 +150,17 @@ function buildNodePaths(nodes, namesUsed) {
|
|
|
123
150
|
*/
|
|
124
151
|
export function extractComponentBindings(json) {
|
|
125
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
|
+
}
|
|
126
164
|
/** @type {Array<{nodeName: string, nodePath: string, componentName: string, fields: Record<string, unknown>, nodeThreeType: string}>} */
|
|
127
165
|
const results = [];
|
|
128
166
|
/** @type {Record<string, number>} */
|
|
@@ -138,7 +176,9 @@ export function extractComponentBindings(json) {
|
|
|
138
176
|
if (!nodeName) continue;
|
|
139
177
|
|
|
140
178
|
const nodePath = pathMap.get(i) ?? nodeName;
|
|
141
|
-
const nodeThreeType =
|
|
179
|
+
const nodeThreeType = sceneRootNodeIndices.has(i)
|
|
180
|
+
? `import("three").Scene`
|
|
181
|
+
: inferNodeThreeType(/** @type {Record<string, unknown>} */ (node), cameras, meshes);
|
|
142
182
|
const ext = node.extensions?.[NEEDLE_COMPONENTS_EXTENSION];
|
|
143
183
|
|
|
144
184
|
if (!ext || typeof ext !== "object") {
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { openSync, readSync, closeSync, readFileSync } from 'fs';
|
|
10
|
+
import { needleLog } from '../vite/logging.js';
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
const _remoteGlbCache = new Map();
|
|
12
|
+
const PLUGIN = "needle:dts-generator";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Read the JSON chunk from a binary GLB file without loading the binary blob.
|
|
@@ -56,12 +56,39 @@ export function readGltfJsonFile(filePath) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
*
|
|
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.
|
|
60
86
|
* Uses `Last-Modified` header for caching — avoids re-parsing if unchanged.
|
|
61
|
-
*
|
|
87
|
+
* Also captures `Content-Disposition` filename for friendly key generation.
|
|
88
|
+
* Returns `null` on any network or parse error.
|
|
62
89
|
*
|
|
63
90
|
* @param {string} url
|
|
64
|
-
* @returns {Promise<Record<string, unknown
|
|
91
|
+
* @returns {Promise<{ json: Record<string, unknown>, filename: string | null } | null>}
|
|
65
92
|
*/
|
|
66
93
|
export async function readRemoteGlbJsonChunk(url) {
|
|
67
94
|
try {
|
|
@@ -69,46 +96,72 @@ export async function readRemoteGlbJsonChunk(url) {
|
|
|
69
96
|
|
|
70
97
|
const headerRes = await fetch(url, { headers: { Range: "bytes=0-19" } });
|
|
71
98
|
if (!headerRes.ok) {
|
|
72
|
-
|
|
99
|
+
needleLog(PLUGIN, `Remote GLB fetch failed (${headerRes.status}): ${url}`, "warn");
|
|
73
100
|
return null;
|
|
74
101
|
}
|
|
75
102
|
|
|
76
103
|
const lastModified = headerRes.headers.get("Last-Modified") ?? "";
|
|
104
|
+
const filename = parseContentDispositionFilename(headerRes.headers.get("Content-Disposition"));
|
|
105
|
+
|
|
77
106
|
if (cached && lastModified && cached.lastModified === lastModified) {
|
|
78
|
-
|
|
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 };
|
|
79
110
|
}
|
|
80
111
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (headerBytes.length < 20) return null;
|
|
112
|
+
const firstBytes = Buffer.from(await headerRes.arrayBuffer());
|
|
113
|
+
if (firstBytes.length < 20) return null;
|
|
84
114
|
|
|
85
|
-
const magic =
|
|
86
|
-
const version =
|
|
87
|
-
const chunkLength =
|
|
88
|
-
const chunkType =
|
|
115
|
+
const magic = firstBytes.readUInt32LE(0);
|
|
116
|
+
const version = firstBytes.readUInt32LE(4);
|
|
117
|
+
const chunkLength = firstBytes.readUInt32LE(12);
|
|
118
|
+
const chunkType = firstBytes.readUInt32LE(16);
|
|
89
119
|
|
|
90
120
|
if (magic !== 0x46546c67 || version !== 2) return null; // not a GLB
|
|
91
121
|
if (chunkType !== 0x4E4F534A) return null; // chunk0 not JSON
|
|
92
122
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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;
|
|
96
150
|
}
|
|
97
151
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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 */ }
|
|
103
157
|
}
|
|
104
|
-
|
|
105
158
|
const json = /** @type {Record<string, unknown>} */ (
|
|
106
|
-
JSON.parse(
|
|
159
|
+
JSON.parse(jsonBytes.toString("utf8").replace(/\u0000+$/g, ""))
|
|
107
160
|
);
|
|
108
|
-
_remoteGlbCache.set(url, { lastModified, json });
|
|
109
|
-
return json;
|
|
161
|
+
_remoteGlbCache.set(url, { lastModified, json, filename: resolvedFilename });
|
|
162
|
+
return { json, filename: resolvedFilename };
|
|
110
163
|
} catch (e) {
|
|
111
|
-
|
|
164
|
+
needleLog(PLUGIN, `Failed to fetch remote GLB: ${url} — ${/** @type {any} */ (e)?.message ?? e}`, "warn");
|
|
112
165
|
return null;
|
|
113
166
|
}
|
|
114
167
|
}
|
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Ambient module declaration for `needle
|
|
2
|
+
* Ambient module declaration for `needle-bindings`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* `SceneData` is keyed by GLB friendly name, then by the node hierarchy:
|
|
5
|
+
* ctx.sceneData.Minimal.Camera.$object // → THREE.Camera
|
|
6
|
+
* ctx.sceneData.Minimal.Camera.$components.OrbitControls.autoRotate = true;
|
|
7
|
+
* ctx.sceneData.Minimal.UI.Button.$components.Button
|
|
8
|
+
*
|
|
9
|
+
* Each node entry has:
|
|
10
|
+
* $object — the Three.js Object3D (typed precisely, e.g. Mesh, Camera, Light)
|
|
11
|
+
* $components — Needle components attached to this node
|
|
12
|
+
* [childName] — child nodes, recursively typed
|
|
9
13
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* When the `needle:dts-generator` Vite plugin is active it writes
|
|
15
|
+
* `.needle/generated/needle-bindings.gen.d.ts` next to the installed package,
|
|
16
|
+
* which augments this interface with the actual bindings extracted from
|
|
17
|
+
* the project's GLB files at build time.
|
|
13
18
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
// Pull in the project-local generated augmentation (written by needle:dts-generator).
|
|
21
|
+
/// <reference path="../../.needle/generated/needle-bindings.gen.d.ts" />
|
|
22
|
+
|
|
23
|
+
declare module "needle-bindings" {
|
|
24
|
+
/**
|
|
25
|
+
* Scene data keyed by GLB friendly name, then by node hierarchy.
|
|
26
|
+
* Fallback index signature allows unknown names without crashing.
|
|
27
|
+
*/
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
29
|
+
interface SceneData {}
|
|
19
30
|
}
|
|
@@ -176,4 +176,16 @@ export type userSettings = {
|
|
|
176
176
|
|
|
177
177
|
/** Set to true to disable the plugin that ensures VSCode workspace settings for custom-elements.json data */
|
|
178
178
|
noCustomElementData?: boolean;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Generate Typescript declaration files for references 3D assets in your project.
|
|
182
|
+
* These will be available via `context.sceneData` in your code.
|
|
183
|
+
* @default enabled
|
|
184
|
+
*/
|
|
185
|
+
dts?: {
|
|
186
|
+
/** When set to false, disables the generation of TypeScript declaration files.
|
|
187
|
+
* @default true
|
|
188
|
+
*/
|
|
189
|
+
enabled?: boolean;
|
|
190
|
+
}
|
|
179
191
|
}
|
package/plugins/vite/asap.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @param {"build" | "serve"}
|
|
2
|
+
* @param {"build" | "serve" | undefined} _command
|
|
3
3
|
* @param {unknown} _config
|
|
4
4
|
* @param {import('../types').userSettings} userSettings
|
|
5
5
|
* @returns {import('vite').Plugin | null}
|
|
6
6
|
*/
|
|
7
|
-
export function needleDependencyWatcher(
|
|
7
|
+
export function needleDependencyWatcher(_command: "build" | "serve" | undefined, _config: unknown, userSettings: import("../types").userSettings): import("vite").Plugin | null;
|
|
8
8
|
export type PackageJson = {
|
|
9
9
|
dependencies?: Record<string, string>;
|
|
10
10
|
devDependencies?: Record<string, string>;
|
|
@@ -12,14 +12,12 @@ function log(...msg) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* @param {"build" | "serve"}
|
|
15
|
+
* @param {"build" | "serve" | undefined} _command
|
|
16
16
|
* @param {unknown} _config
|
|
17
17
|
* @param {import('../types').userSettings} userSettings
|
|
18
18
|
* @returns {import('vite').Plugin | null}
|
|
19
19
|
*/
|
|
20
|
-
export function needleDependencyWatcher(
|
|
21
|
-
if (command === "build") return null;
|
|
22
|
-
|
|
20
|
+
export function needleDependencyWatcher(_command, _config, userSettings) {
|
|
23
21
|
if (userSettings?.noDependencyWatcher === true) return null;
|
|
24
22
|
|
|
25
23
|
const dir = process.cwd();
|
|
@@ -28,6 +26,7 @@ export function needleDependencyWatcher(command, _config, userSettings) {
|
|
|
28
26
|
|
|
29
27
|
return /** @type {import('vite').Plugin} */ ({
|
|
30
28
|
name: 'needle-dependency-watcher',
|
|
29
|
+
apply: 'serve',
|
|
31
30
|
/** @param {import('vite').ViteDevServer} server */
|
|
32
31
|
configureServer(server) {
|
|
33
32
|
manageClients(server);
|
package/plugins/vite/drop.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Experimental, allow dropping files from Unity into the running scene.
|
|
2
|
-
* @param {"build" | "serve"}
|
|
2
|
+
* @param {"build" | "serve" | undefined} _command
|
|
3
3
|
* @param {import('../types/needleConfig').needleMeta | null | undefined} config
|
|
4
4
|
* @param {import('../types/userconfig.js').userSettings} userSettings
|
|
5
5
|
* @returns {import('vite').Plugin | null | undefined}
|
|
6
6
|
*/
|
|
7
|
-
export function needleDrop(
|
|
7
|
+
export function needleDrop(_command: "build" | "serve" | undefined, config: import("../types/needleConfig").needleMeta | null | undefined, userSettings: import("../types/userconfig.js").userSettings): import("vite").Plugin | null | undefined;
|
package/plugins/vite/drop.js
CHANGED
|
@@ -7,18 +7,17 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
7
7
|
const __dirname = path.dirname(__filename);
|
|
8
8
|
|
|
9
9
|
/** Experimental, allow dropping files from Unity into the running scene.
|
|
10
|
-
* @param {"build" | "serve"}
|
|
10
|
+
* @param {"build" | "serve" | undefined} _command
|
|
11
11
|
* @param {import('../types/needleConfig').needleMeta | null | undefined} config
|
|
12
12
|
* @param {import('../types/userconfig.js').userSettings} userSettings
|
|
13
13
|
* @returns {import('vite').Plugin | null | undefined}
|
|
14
14
|
*/
|
|
15
|
-
export function needleDrop(
|
|
16
|
-
if (command === "build") return;
|
|
17
|
-
|
|
15
|
+
export function needleDrop(_command, config, userSettings) {
|
|
18
16
|
if(userSettings.useDrop !== true) return null;
|
|
19
17
|
|
|
20
18
|
return {
|
|
21
19
|
name: "needle:drop",
|
|
20
|
+
apply: 'serve',
|
|
22
21
|
config(/** @type {{ server?: { hmr?: { port?: number } } }} */ viteConfig) {
|
|
23
22
|
if(userSettings)
|
|
24
23
|
if (!viteConfig.server) viteConfig.server = {};
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
* @param {"build" | "serve"} _command Vite command (unused — runs in both modes)
|
|
3
3
|
* @param {import('../types/needleConfig').needleMeta | null | undefined} _config
|
|
4
4
|
* @param {import('../types').userSettings} [_userSettings]
|
|
5
|
-
* @returns {import('vite').Plugin}
|
|
5
|
+
* @returns {import('vite').Plugin | null}
|
|
6
6
|
*/
|
|
7
|
-
export function needleDtsGenerator(_command: "build" | "serve", _config: import("../types/needleConfig").needleMeta | null | undefined, _userSettings?: import("../types").userSettings): import("vite").Plugin;
|
|
7
|
+
export function needleDtsGenerator(_command: "build" | "serve", _config: import("../types/needleConfig").needleMeta | null | undefined, _userSettings?: import("../types").userSettings): import("vite").Plugin | null;
|