@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.
Files changed (106) hide show
  1. package/.needle/generated/needle-bindings.gen.d.ts +5 -0
  2. package/CHANGELOG.md +34 -0
  3. package/components.needle.json +1 -1
  4. package/dist/{needle-engine.bundle-YnpzzOPL.min.js → needle-engine.bundle-1s2gOoKZ.min.js} +144 -144
  5. package/dist/{needle-engine.bundle-B29kieh0.js → needle-engine.bundle-CvtELXh0.js} +6650 -6584
  6. package/dist/{needle-engine.bundle-Dq0Ly8fW.umd.cjs → needle-engine.bundle-j4nGJXCs.umd.cjs} +138 -138
  7. package/dist/needle-engine.d.ts +101 -89
  8. package/dist/needle-engine.js +188 -186
  9. package/dist/needle-engine.min.js +1 -1
  10. package/dist/needle-engine.umd.cjs +1 -1
  11. package/lib/engine/api.d.ts +1 -1
  12. package/lib/engine/debug/debug_spatial_console.d.ts +2 -0
  13. package/lib/engine/debug/debug_spatial_console.js +10 -7
  14. package/lib/engine/debug/debug_spatial_console.js.map +1 -1
  15. package/lib/engine/engine_addressables.d.ts +2 -0
  16. package/lib/engine/engine_addressables.js +6 -3
  17. package/lib/engine/engine_addressables.js.map +1 -1
  18. package/lib/engine/engine_context.d.ts +21 -20
  19. package/lib/engine/engine_context.js +25 -14
  20. package/lib/engine/engine_context.js.map +1 -1
  21. package/lib/engine/engine_init.js +15 -0
  22. package/lib/engine/engine_init.js.map +1 -1
  23. package/lib/engine/engine_license.d.ts +2 -0
  24. package/lib/engine/engine_license.js +14 -6
  25. package/lib/engine/engine_license.js.map +1 -1
  26. package/lib/engine/engine_lifecycle_functions_internal.js +5 -0
  27. package/lib/engine/engine_lifecycle_functions_internal.js.map +1 -1
  28. package/lib/engine/engine_pmrem.js +2 -2
  29. package/lib/engine/engine_pmrem.js.map +1 -1
  30. package/lib/engine/engine_scenedata.d.ts +13 -17
  31. package/lib/engine/engine_scenedata.js +56 -29
  32. package/lib/engine/engine_scenedata.js.map +1 -1
  33. package/lib/engine/engine_serialization_builtin_serializer.d.ts +10 -16
  34. package/lib/engine/engine_serialization_builtin_serializer.js +28 -41
  35. package/lib/engine/engine_serialization_builtin_serializer.js.map +1 -1
  36. package/lib/engine/engine_ssr.d.ts +2 -0
  37. package/lib/engine/engine_ssr.js +20 -0
  38. package/lib/engine/engine_ssr.js.map +1 -1
  39. package/lib/engine/engine_types.d.ts +2 -0
  40. package/lib/engine/engine_types.js.map +1 -1
  41. package/lib/engine/webcomponents/jsx.d.ts +51 -0
  42. package/lib/engine/webcomponents/logo-element.js.map +1 -1
  43. package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +2 -3
  44. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  45. package/lib/engine/webcomponents/needle-button.js.map +1 -1
  46. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  47. package/lib/engine-components/AnimatorController.d.ts +2 -0
  48. package/lib/engine-components/AnimatorController.js +4 -1
  49. package/lib/engine-components/AnimatorController.js.map +1 -1
  50. package/lib/engine-components/Light.d.ts +6 -8
  51. package/lib/engine-components/Light.js +40 -27
  52. package/lib/engine-components/Light.js.map +1 -1
  53. package/lib/engine-components/ReflectionProbe.js +2 -0
  54. package/lib/engine-components/ReflectionProbe.js.map +1 -1
  55. package/lib/engine-components/postprocessing/VolumeParameter.d.ts +2 -0
  56. package/lib/engine-components/postprocessing/VolumeParameter.js +4 -1
  57. package/lib/engine-components/postprocessing/VolumeParameter.js.map +1 -1
  58. package/lib/needle-engine.d.ts +2 -0
  59. package/lib/needle-engine.js +2 -0
  60. package/lib/needle-engine.js.map +1 -1
  61. package/package.json +3 -2
  62. package/plugins/dts-generator/dts.codegen.js +255 -50
  63. package/plugins/dts-generator/dts.scan.js +37 -9
  64. package/plugins/dts-generator/dts.writer.js +1 -1
  65. package/plugins/dts-generator/glb.discovery.js +140 -23
  66. package/plugins/dts-generator/glb.extractor.js +48 -8
  67. package/plugins/dts-generator/glb.reader.js +80 -27
  68. package/plugins/dts-generator/index.js +1 -1
  69. package/plugins/types/needle-bindings.d.ts +25 -14
  70. package/plugins/types/userconfig.d.ts +12 -0
  71. package/plugins/vite/asap.js +1 -1
  72. package/plugins/vite/dependency-watcher.d.ts +2 -2
  73. package/plugins/vite/dependency-watcher.js +3 -4
  74. package/plugins/vite/drop.d.ts +2 -2
  75. package/plugins/vite/drop.js +3 -4
  76. package/plugins/vite/dts-generator.d.ts +2 -2
  77. package/plugins/vite/dts-generator.js +43 -9
  78. package/plugins/vite/index.d.ts +9 -3
  79. package/plugins/vite/index.js +23 -10
  80. package/plugins/vite/meta.js +4 -2
  81. package/plugins/vite/poster.d.ts +2 -2
  82. package/plugins/vite/poster.js +3 -5
  83. package/plugins/vite/reload.d.ts +2 -2
  84. package/plugins/vite/reload.js +22 -22
  85. package/src/engine/api.ts +1 -1
  86. package/src/engine/debug/debug_spatial_console.ts +10 -7
  87. package/src/engine/engine_addressables.ts +6 -3
  88. package/src/engine/engine_context.ts +34 -20
  89. package/src/engine/engine_init.ts +14 -0
  90. package/src/engine/engine_license.ts +12 -10
  91. package/src/engine/engine_lifecycle_functions_internal.ts +7 -0
  92. package/src/engine/engine_pmrem.ts +3 -3
  93. package/src/engine/engine_scenedata.ts +53 -27
  94. package/src/engine/engine_serialization_builtin_serializer.ts +32 -43
  95. package/src/engine/engine_ssr.ts +29 -3
  96. package/src/engine/engine_types.ts +2 -0
  97. package/src/engine/webcomponents/jsx.d.ts +51 -0
  98. package/src/engine/webcomponents/logo-element.ts +1 -0
  99. package/src/engine/webcomponents/needle menu/needle-menu.ts +2 -1
  100. package/src/engine/webcomponents/needle-button.ts +1 -0
  101. package/src/engine/webcomponents/needle-engine.ts +1 -0
  102. package/src/engine-components/AnimatorController.ts +4 -1
  103. package/src/engine-components/Light.ts +40 -26
  104. package/src/engine-components/ReflectionProbe.ts +2 -0
  105. package/src/engine-components/postprocessing/VolumeParameter.ts +4 -1
  106. 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, resolve } from 'path';
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[^>]*\ssrc=["']([^"']+)["']/gi;
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[]} paths
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(paths, projectRoot, assetsDir) {
64
- /** @type {Array<{path: string, type: "glb"|"gltf", remote?: boolean}>} */
65
- const out = [];
66
- for (const p of paths) {
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
- out.push({ path: p, type, remote: true });
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
- out.push({ path: candidate, type });
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
- * Priority:
92
- * 1. `<needle-engine src="...">` attribute in `index.html`
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. Returns `null` caller should fall back to `collectSceneFiles`.
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 srcPaths = parseSrcAttribute(readFileSync(htmlPath, "utf8"));
106
- if (srcPaths.length > 0) {
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 genPaths = parseGenJs(readFileSync(genPath, "utf8"));
118
- if (genPaths.length > 0) {
119
- const resolved = resolveGlbPaths(genPaths, projectRoot, assetsDir);
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
- return null;
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 → `import("three").Mesh`
33
- * - `camera` present → `import("three").Camera`
34
- * - KHR_lights_punctual → `import("three").Light`
35
- * - otherwise → `import("three").Object3D`
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) return `import("three").Mesh`;
42
- if ("camera" in node) return `import("three").Camera`;
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 = inferNodeThreeType(/** @type {Record<string, unknown>} */ (node));
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
- /** @type {Map<string, { lastModified: string, json: Record<string, unknown> }>} */
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
- * Fetch only the JSON chunk of a remote GLB using two HTTP Range requests.
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
- * Returns `null` on any network/parse error or if Range is not supported.
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> | null>}
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
- console.warn(`[needle:dts-generator] Remote GLB fetch failed (${headerRes.status}): ${url}`);
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
- return cached.json;
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 rangeSupported = headerRes.status === 206;
82
- const headerBytes = Buffer.from(await headerRes.arrayBuffer());
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 = headerBytes.readUInt32LE(0);
86
- const version = headerBytes.readUInt32LE(4);
87
- const chunkLength = headerBytes.readUInt32LE(12);
88
- const chunkType = headerBytes.readUInt32LE(16);
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
- if (!rangeSupported) {
94
- console.warn(`[needle:dts-generator] Remote GLB server does not support Range requests, skipping: ${url}`);
95
- return null;
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
- const jsonEnd = 20 + chunkLength - 1;
99
- const jsonRes = await fetch(url, { headers: { Range: `bytes=20-${jsonEnd}` } });
100
- if (!jsonRes.ok) {
101
- console.warn(`[needle:dts-generator] Remote GLB JSON chunk fetch failed (${jsonRes.status}): ${url}`);
102
- return null;
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(Buffer.from(await jsonRes.arrayBuffer()).toString("utf8").replace(/\u0000+$/g, ""))
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
- console.warn(`[needle:dts-generator] Failed to fetch remote GLB: ${url} —`, /** @type {any} */ (e)?.message ?? e);
164
+ needleLog(PLUGIN, `Failed to fetch remote GLB: ${url} ${/** @type {any} */ (e)?.message ?? e}`, "warn");
112
165
  return null;
113
166
  }
114
167
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Typical generated output:
10
10
  *
11
- * declare module "needle:bindings" {
11
+ * declare module "needle-bindings" {
12
12
  * interface SceneData {
13
13
  * Sphere: {
14
14
  * MyBall: { speed: number; label: string; };
@@ -1,19 +1,30 @@
1
1
  /**
2
- * Ambient module declaration for `needle:bindings`.
2
+ * Ambient module declaration for `needle-bindings`.
3
3
  *
4
- * The `SceneData` interface is empty by default.
5
- * When the `needle:dts-generator` Vite plugin is active it writes
6
- * `src/generated/needle-bindings.d.ts` into the user's project, which
7
- * augments this interface with the actual component bindings extracted
8
- * from the project's GLB files at build time.
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
- * @example
11
- * import type { SceneData } from "@needle-tools/engine";
12
- * type MyBallData = SceneData["RedBall"]["MyBall"];
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
- declare module "needle:bindings" {
15
- interface SceneData {
16
- /** Fallback for nodes not present in the generated bindings — no type info, but no crash. */
17
- [nodeName: string]: Record<string, unknown> | undefined;
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
  }
@@ -22,7 +22,7 @@ export async function needleAsap(command, config, userSettings) {
22
22
 
23
23
  fixMainTs();
24
24
 
25
- if (command != "build") {
25
+ if (command === "serve") {
26
26
  return null;
27
27
  }
28
28
 
@@ -1,10 +1,10 @@
1
1
  /**
2
- * @param {"build" | "serve"} command
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(command: "build" | "serve", _config: unknown, userSettings: import("../types").userSettings): import("vite").Plugin | null;
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"} command
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(command, _config, userSettings) {
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);
@@ -1,7 +1,7 @@
1
1
  /** Experimental, allow dropping files from Unity into the running scene.
2
- * @param {"build" | "serve"} command
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(command: "build" | "serve", config: import("../types/needleConfig").needleMeta | null | undefined, userSettings: import("../types/userconfig.js").userSettings): import("vite").Plugin | null | undefined;
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;
@@ -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"} command
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(command, config, userSettings) {
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;