@needle-tools/engine 5.1.0-alpha → 5.1.0-alpha.2

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 (193) hide show
  1. package/.needle/generated/needle-bindings.gen.d.ts +5 -0
  2. package/CHANGELOG.md +27 -1
  3. package/SKILL.md +39 -21
  4. package/components.needle.json +1 -1
  5. package/dist/{gltf-progressive-DJBMx-zB.umd.cjs → gltf-progressive-BmblPzFj.umd.cjs} +4 -4
  6. package/dist/{gltf-progressive-BryRjllq.min.js → gltf-progressive-CN_mbb66.min.js} +2 -2
  7. package/dist/{gltf-progressive-Cl167Vjx.js → gltf-progressive-DUlhxdv4.js} +5 -2
  8. package/dist/{needle-engine.bundle-qDahLTqW.min.js → needle-engine.bundle-B-5Q2CpC.min.js} +249 -173
  9. package/dist/{needle-engine.bundle-CwhCzjep.js → needle-engine.bundle-dit3f1l5.js} +13238 -12724
  10. package/dist/{needle-engine.bundle-wM-BWPX9.umd.cjs → needle-engine.bundle-qZfVf_v-.umd.cjs} +250 -174
  11. package/dist/needle-engine.d.ts +295 -31
  12. package/dist/needle-engine.js +569 -563
  13. package/dist/needle-engine.min.js +1 -1
  14. package/dist/needle-engine.umd.cjs +1 -1
  15. package/dist/{postprocessing-B_9sKVU7.min.js → postprocessing-B571qGWR.min.js} +34 -34
  16. package/dist/{postprocessing-WDc9WwI3.js → postprocessing-CfrLAbLX.js} +0 -1
  17. package/dist/{postprocessing-B2wb6pzI.umd.cjs → postprocessing-CiGkAeM9.umd.cjs} +17 -17
  18. package/dist/{vendor-CAcsI0eU.js → vendor-BFrMaK9q.js} +8983 -9136
  19. package/dist/vendor-CJmyOrCq.min.js +1116 -0
  20. package/dist/vendor-DkMW3WY4.umd.cjs +1116 -0
  21. package/lib/engine/api.d.ts +12 -0
  22. package/lib/engine/api.js +2 -0
  23. package/lib/engine/api.js.map +1 -1
  24. package/lib/engine/debug/debug_environment.js +1 -1
  25. package/lib/engine/debug/debug_environment.js.map +1 -1
  26. package/lib/engine/engine_application.js +8 -6
  27. package/lib/engine/engine_application.js.map +1 -1
  28. package/lib/engine/engine_components.js +5 -1
  29. package/lib/engine/engine_components.js.map +1 -1
  30. package/lib/engine/engine_constants.js +6 -0
  31. package/lib/engine/engine_constants.js.map +1 -1
  32. package/lib/engine/engine_context.d.ts +33 -7
  33. package/lib/engine/engine_context.js +40 -2
  34. package/lib/engine/engine_context.js.map +1 -1
  35. package/lib/engine/engine_context_registry.js +1 -1
  36. package/lib/engine/engine_context_registry.js.map +1 -1
  37. package/lib/engine/engine_init.js +7 -0
  38. package/lib/engine/engine_init.js.map +1 -1
  39. package/lib/engine/engine_input.d.ts +3 -2
  40. package/lib/engine/engine_input.js +3 -2
  41. package/lib/engine/engine_input.js.map +1 -1
  42. package/lib/engine/engine_license.d.ts +2 -0
  43. package/lib/engine/engine_license.js +25 -15
  44. package/lib/engine/engine_license.js.map +1 -1
  45. package/lib/engine/engine_lifecycle_functions_internal.js +5 -0
  46. package/lib/engine/engine_lifecycle_functions_internal.js.map +1 -1
  47. package/lib/engine/engine_networking_blob.d.ts +1 -1
  48. package/lib/engine/engine_networking_blob.js +5 -11
  49. package/lib/engine/engine_networking_blob.js.map +1 -1
  50. package/lib/engine/engine_physics_rapier.d.ts +3 -0
  51. package/lib/engine/engine_physics_rapier.js +13 -10
  52. package/lib/engine/engine_physics_rapier.js.map +1 -1
  53. package/lib/engine/engine_pmrem.js +2 -2
  54. package/lib/engine/engine_pmrem.js.map +1 -1
  55. package/lib/engine/engine_scenedata.d.ts +30 -0
  56. package/lib/engine/engine_scenedata.js +136 -0
  57. package/lib/engine/engine_scenedata.js.map +1 -0
  58. package/lib/engine/engine_ssr.d.ts +18 -0
  59. package/lib/engine/engine_ssr.js +40 -0
  60. package/lib/engine/engine_ssr.js.map +1 -0
  61. package/lib/engine/engine_three_utils.d.ts +14 -7
  62. package/lib/engine/engine_three_utils.js +14 -7
  63. package/lib/engine/engine_three_utils.js.map +1 -1
  64. package/lib/engine/engine_types.d.ts +2 -0
  65. package/lib/engine/engine_types.js.map +1 -1
  66. package/lib/engine/engine_utils.js +4 -2
  67. package/lib/engine/engine_utils.js.map +1 -1
  68. package/lib/engine/engine_utils_hash.d.ts +9 -0
  69. package/lib/engine/engine_utils_hash.js +112 -0
  70. package/lib/engine/engine_utils_hash.js.map +1 -0
  71. package/lib/engine/webcomponents/jsx.d.ts +51 -0
  72. package/lib/engine/webcomponents/logo-element.d.ts +2 -1
  73. package/lib/engine/webcomponents/logo-element.js +2 -1
  74. package/lib/engine/webcomponents/logo-element.js.map +1 -1
  75. package/lib/engine/webcomponents/needle menu/needle-menu.d.ts +4 -4
  76. package/lib/engine/webcomponents/needle menu/needle-menu.js +2 -1
  77. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  78. package/lib/engine/webcomponents/needle-button.d.ts +2 -1
  79. package/lib/engine/webcomponents/needle-button.js +2 -1
  80. package/lib/engine/webcomponents/needle-button.js.map +1 -1
  81. package/lib/engine/webcomponents/needle-engine.d.ts +2 -1
  82. package/lib/engine/webcomponents/needle-engine.js +2 -1
  83. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  84. package/lib/engine/xr/NeedleXRSession.d.ts +1 -0
  85. package/lib/engine/xr/NeedleXRSession.js +5 -5
  86. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  87. package/lib/engine/xr/events.d.ts +30 -3
  88. package/lib/engine/xr/events.js +38 -0
  89. package/lib/engine/xr/events.js.map +1 -1
  90. package/lib/engine/xr/init.js +1 -7
  91. package/lib/engine/xr/init.js.map +1 -1
  92. package/lib/engine-components/AnimatorController.d.ts +135 -2
  93. package/lib/engine-components/AnimatorController.js +218 -2
  94. package/lib/engine-components/AnimatorController.js.map +1 -1
  95. package/lib/engine-components/GroundProjection.d.ts +1 -0
  96. package/lib/engine-components/GroundProjection.js +184 -48
  97. package/lib/engine-components/GroundProjection.js.map +1 -1
  98. package/lib/engine-components/Light.d.ts +6 -8
  99. package/lib/engine-components/Light.js +40 -27
  100. package/lib/engine-components/Light.js.map +1 -1
  101. package/lib/engine-components/RigidBody.js +3 -3
  102. package/lib/engine-components/RigidBody.js.map +1 -1
  103. package/lib/engine-components/SceneSwitcher.js +2 -0
  104. package/lib/engine-components/SceneSwitcher.js.map +1 -1
  105. package/lib/engine-components/api.d.ts +1 -0
  106. package/lib/engine-components/api.js +1 -0
  107. package/lib/engine-components/api.js.map +1 -1
  108. package/lib/engine-components/codegen/components.d.ts +1 -0
  109. package/lib/engine-components/codegen/components.js +1 -0
  110. package/lib/engine-components/codegen/components.js.map +1 -1
  111. package/lib/engine-components/postprocessing/Effects/BloomEffect.d.ts +1 -1
  112. package/lib/engine-components/postprocessing/Effects/Sharpening.js +1 -2
  113. package/lib/engine-components/postprocessing/Effects/Sharpening.js.map +1 -1
  114. package/lib/engine-components/postprocessing/PostProcessingHandler.js +5 -6
  115. package/lib/engine-components/postprocessing/PostProcessingHandler.js.map +1 -1
  116. package/lib/engine-components/web/ScrollFollow.d.ts +0 -1
  117. package/lib/engine-components/web/ScrollFollow.js +3 -2
  118. package/lib/engine-components/web/ScrollFollow.js.map +1 -1
  119. package/lib/needle-engine.d.ts +2 -0
  120. package/lib/needle-engine.js +2 -0
  121. package/lib/needle-engine.js.map +1 -1
  122. package/package.json +6 -4
  123. package/plugins/common/logger.js +42 -19
  124. package/plugins/dts-generator/dts.codegen.js +334 -0
  125. package/plugins/dts-generator/dts.scan.js +99 -0
  126. package/plugins/dts-generator/dts.writer.js +59 -0
  127. package/plugins/dts-generator/glb.discovery.js +279 -0
  128. package/plugins/dts-generator/glb.extractor.js +215 -0
  129. package/plugins/dts-generator/glb.reader.js +167 -0
  130. package/plugins/dts-generator/index.js +36 -0
  131. package/plugins/dts-generator/manifest.types.js +174 -0
  132. package/plugins/types/index.d.ts +2 -1
  133. package/plugins/types/needle-bindings.d.ts +30 -0
  134. package/plugins/types/userconfig.d.ts +21 -2
  135. package/plugins/vite/asap.js +1 -1
  136. package/plugins/vite/dependency-watcher.d.ts +2 -2
  137. package/plugins/vite/dependency-watcher.js +3 -4
  138. package/plugins/vite/drop.d.ts +2 -2
  139. package/plugins/vite/drop.js +3 -4
  140. package/plugins/vite/dts-generator.d.ts +7 -0
  141. package/plugins/vite/dts-generator.js +191 -0
  142. package/plugins/vite/index.d.ts +10 -3
  143. package/plugins/vite/index.js +27 -10
  144. package/plugins/vite/logger.client.js +4 -3
  145. package/plugins/vite/logging.js +2 -2
  146. package/plugins/vite/meta.js +4 -2
  147. package/plugins/vite/poster.d.ts +2 -2
  148. package/plugins/vite/poster.js +3 -5
  149. package/plugins/vite/reload.d.ts +2 -2
  150. package/plugins/vite/reload.js +23 -22
  151. package/src/engine/api.ts +15 -1
  152. package/src/engine/debug/debug_environment.ts +1 -1
  153. package/src/engine/engine_application.ts +8 -6
  154. package/src/engine/engine_components.ts +7 -4
  155. package/src/engine/engine_constants.ts +11 -6
  156. package/src/engine/engine_context.ts +50 -7
  157. package/src/engine/engine_context_registry.ts +1 -1
  158. package/src/engine/engine_init.ts +6 -0
  159. package/src/engine/engine_input.ts +3 -2
  160. package/src/engine/engine_license.ts +23 -19
  161. package/src/engine/engine_lifecycle_functions_internal.ts +7 -0
  162. package/src/engine/engine_networking_blob.ts +5 -11
  163. package/src/engine/engine_physics_rapier.ts +14 -12
  164. package/src/engine/engine_pmrem.ts +3 -3
  165. package/src/engine/engine_scenedata.ts +134 -0
  166. package/src/engine/engine_ssr.ts +48 -0
  167. package/src/engine/engine_three_utils.ts +15 -7
  168. package/src/engine/engine_types.ts +2 -0
  169. package/src/engine/engine_utils.ts +3 -2
  170. package/src/engine/engine_utils_hash.ts +65 -0
  171. package/src/engine/webcomponents/jsx.d.ts +51 -0
  172. package/src/engine/webcomponents/logo-element.ts +3 -1
  173. package/src/engine/webcomponents/needle menu/needle-menu.ts +4 -2
  174. package/src/engine/webcomponents/needle-button.ts +3 -1
  175. package/src/engine/webcomponents/needle-engine.ts +3 -1
  176. package/src/engine/xr/NeedleXRSession.ts +6 -6
  177. package/src/engine/xr/events.ts +44 -1
  178. package/src/engine/xr/init.ts +0 -7
  179. package/src/engine-components/AnimatorController.ts +286 -4
  180. package/src/engine-components/GroundProjection.ts +226 -52
  181. package/src/engine-components/Light.ts +40 -26
  182. package/src/engine-components/RigidBody.ts +3 -3
  183. package/src/engine-components/SceneSwitcher.ts +1 -0
  184. package/src/engine-components/api.ts +1 -0
  185. package/src/engine-components/codegen/components.ts +1 -0
  186. package/src/engine-components/postprocessing/Effects/BloomEffect.ts +1 -1
  187. package/src/engine-components/postprocessing/Effects/Sharpening.ts +1 -2
  188. package/src/engine-components/postprocessing/PostProcessingHandler.ts +4 -8
  189. package/src/engine-components/web/ScrollFollow.ts +2 -2
  190. package/src/needle-engine.ts +3 -0
  191. package/src/vite-env.d.ts +16 -0
  192. package/dist/vendor-CEM38hLE.umd.cjs +0 -1116
  193. package/dist/vendor-HRlxIBga.min.js +0 -1116
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/engine",
3
- "version": "5.1.0-alpha",
3
+ "version": "5.1.0-alpha.2",
4
4
  "description": "Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.",
5
5
  "main": "dist/needle-engine.min.js",
6
6
  "exports": {
@@ -68,6 +68,7 @@
68
68
  "build:plugin-types": "tsc -p tsconfig.plugins.json && cp .plugin-types-tmp/plugins/vite/*.d.ts plugins/vite/ && cp .plugin-types-tmp/plugins/next/*.d.ts plugins/next/ && rm -rf .plugin-types-tmp",
69
69
  "build:components-data": "node plugins/publish/create-component-types.now.mjs",
70
70
  "test": "node plugins/test/test.mjs",
71
+ "test:vitest": "vitest run",
71
72
  "test:playwright:setup": "cd tests/playwright && npm install --prefer-offline && npx playwright install --with-deps chromium",
72
73
  "test:playwright": "npm run test:playwright:setup && npx playwright test",
73
74
  "test:playwright:fast": "npm run test:playwright:setup && npx playwright test --grep @fast",
@@ -94,6 +95,7 @@
94
95
  "lib",
95
96
  "dist",
96
97
  "plugins",
98
+ ".needle",
97
99
  "components.needle.json",
98
100
  "custom-elements.json"
99
101
  ],
@@ -122,12 +124,11 @@
122
124
  ],
123
125
  "dependencies": {
124
126
  "@dimforge/rapier3d-compat": "0.19.3",
125
- "@needle-tools/gltf-progressive": "3.4.0-beta.3",
127
+ "@needle-tools/gltf-progressive": "3.5.0-rc",
126
128
  "@needle-tools/materialx": "1.6.0",
127
129
  "@needle-tools/three-animation-pointer": "1.0.7",
128
130
  "@webxr-input-profiles/motion-controllers": "1.0.0",
129
131
  "flatbuffers": "2.0.4",
130
- "md5": "2.3.0",
131
132
  "n8ao": "1.10.1",
132
133
  "peerjs": "1.4.7",
133
134
  "postprocessing": "6.39.0",
@@ -173,7 +174,8 @@
173
174
  "typescript": "5.9.3",
174
175
  "typescript-eslint": "^8.0.0",
175
176
  "vite": "7",
176
- "vite-plugin-dts": "^4.5.4"
177
+ "vite-plugin-dts": "^4.5.4",
178
+ "vitest": "^4.1.4"
177
179
  },
178
180
  "peerDependencies": {
179
181
  "open": "^10.1.0"
@@ -1,7 +1,13 @@
1
1
  // @ts-check
2
- import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync, write } from "fs";
2
+ import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, lstatSync, write } from "fs";
3
3
 
4
- const filename_timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
4
+ /** Formats a Date as a local-time filename-safe string: YYYY-MM-DD_HH-MM-SS */
5
+ function formatLocalTimestamp(date) {
6
+ const pad = (/** @type {number} */ n) => String(n).padStart(2, '0');
7
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
8
+ }
9
+
10
+ const filename_timestamp = formatLocalTimestamp(new Date());
5
11
  const debug = false;
6
12
 
7
13
  // #region public api
@@ -352,8 +358,8 @@ function stringifyLog(log, seen = /** @type {Set<unknown>} */ (new Set()), depth
352
358
  const isServer = typeof window === "undefined";
353
359
  const stringify_limits = {
354
360
  string: isServer ? 100_000 : 1_000,
355
- object_keys: isServer ? 300 : 200,
356
- object_depth: isServer ? 10 : 3,
361
+ object_keys: isServer ? 10 : 10,
362
+ object_depth: isServer ? 3 : 3,
357
363
  array_items: isServer ? 2_000 : 100,
358
364
  }
359
365
 
@@ -535,21 +541,27 @@ function stripAnsiColors(str) {
535
541
  /** @type {Map<string, import("fs").WriteStream>} */
536
542
  const filestreams = new Map();
537
543
  const fileLogDirectory = "node_modules/.needle/logs";
538
- // cleanup old log files
539
- if (existsSync(fileLogDirectory)) {
540
- const files = readdirSync(fileLogDirectory);
541
- // sort by age and keep the last 10 files
542
- files.sort((a, b) => {
543
- const aStat = statSync(`${fileLogDirectory}/${a}`);
544
- const bStat = statSync(`${fileLogDirectory}/${b}`);
545
- return aStat.mtimeMs - bStat.mtimeMs;
546
- });
547
- // remove all but the last 30 files
548
- const filesToKeep = 30;
549
- for (let i = 0; i < files.length - filesToKeep; i++) {
550
- rmSync(`${fileLogDirectory}/${files[i]}`, { force: true });
544
+ // cleanup old log files (skip symlinks like latest.*.needle.log)
545
+ try {
546
+ if (existsSync(fileLogDirectory)) {
547
+ const files = readdirSync(fileLogDirectory).filter(f => {
548
+ try { return !lstatSync(`${fileLogDirectory}/${f}`).isSymbolicLink(); }
549
+ catch { return false; }
550
+ });
551
+ // sort by age and keep the last 30 files
552
+ files.sort((a, b) => {
553
+ try {
554
+ const aStat = statSync(`${fileLogDirectory}/${a}`);
555
+ const bStat = statSync(`${fileLogDirectory}/${b}`);
556
+ return aStat.mtimeMs - bStat.mtimeMs;
557
+ } catch { return 0; }
558
+ });
559
+ const filesToKeep = 30;
560
+ for (let i = 0; i < files.length - filesToKeep; i++) {
561
+ rmSync(`${fileLogDirectory}/${files[i]}`, { force: true });
562
+ }
551
563
  }
552
- }
564
+ } catch { /* don't let cleanup errors prevent logging from working */ }
553
565
 
554
566
  /**
555
567
  * Appends a single log entry to the per-process rotating file log.
@@ -566,7 +578,18 @@ function writeToFile(process, log, _connectionId) {
566
578
  if (!existsSync(fileLogDirectory)) {
567
579
  mkdirSync(fileLogDirectory, { recursive: true });
568
580
  }
569
- filestreams.set(filename, createWriteStream(`${fileLogDirectory}/${filename_timestamp}.${filename}`, { flags: 'a' }));
581
+ const timestampedName = `${filename_timestamp}.${filename}`;
582
+ filestreams.set(filename, createWriteStream(`${fileLogDirectory}/${timestampedName}`, { flags: 'a' }));
583
+
584
+ // Create/update "latest" symlink pointing to the current log file
585
+ const symlinkName = `latest.${filename}`;
586
+ const symlinkPath = `${fileLogDirectory}/${symlinkName}`;
587
+ try {
588
+ if (lstatSync(symlinkPath).isSymbolicLink()) unlinkSync(symlinkPath);
589
+ } catch { /* doesn't exist yet */ }
590
+ try {
591
+ symlinkSync(timestampedName, symlinkPath);
592
+ } catch { /* symlink creation failed (e.g. Windows without dev mode) */ }
570
593
  }
571
594
  const writeStream = filestreams.get(filename);
572
595
  if (!writeStream) {
@@ -0,0 +1,334 @@
1
+ // @ts-check
2
+ /**
3
+ * Pure string generators — no I/O.
4
+ *
5
+ * Takes `BindingEntry[]` from dts.scan.js and produces:
6
+ * - `needle-bindings.gen.d.ts` (TypeScript ambient module augmentation)
7
+ * - `needle-html-data.json` (VS Code HTML custom data for data-bind-needle completions)
8
+ *
9
+ * Each scene node is emitted as a named type alias so VS Code hover shows
10
+ * the alias name + JSDoc summary rather than expanding the full object type:
11
+ *
12
+ * /** `Minimal/Cube` — MeshRenderer, BoxCollider *\/
13
+ * type $Minimal__Cube = { $object: THREE.Mesh; $components: { ... }; };
14
+ *
15
+ * interface SceneData {
16
+ * Minimal: { Minimal: { Cube: $Minimal__Minimal__Cube; }; }; };
17
+ * }
18
+ */
19
+
20
+ /** @typedef {import('./dts.scan.js').BindingEntry} BindingEntry */
21
+
22
+ /** Append `?view` to Needle Cloud asset URLs so the link opens the viewer. @param {string} url @returns {string} */
23
+ function addViewParam(url) {
24
+ if (!url.includes("cloud.needle.tools")) return url;
25
+ return url.includes("?") ? `${url}&view` : `${url}?view`;
26
+ }
27
+
28
+ /** @param {string} name @returns {string} */
29
+ function propKey(name) {
30
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `"${name}"`;
31
+ }
32
+
33
+ /**
34
+ * Convert a node path like "Minimal/Cube_1/Child Of Cube" to a safe type alias name.
35
+ * @param {string} nodePath
36
+ * @returns {string}
37
+ */
38
+ function typeAliasName(nodePath) {
39
+ const safe = nodePath.replace(/[^a-zA-Z0-9]/g, "_");
40
+ return `$${safe}`;
41
+ }
42
+
43
+ /**
44
+ * @typedef {{
45
+ * threeType: string,
46
+ * components: Map<string, { isEngineComponent: boolean, fields: Map<string, Set<string>> }>,
47
+ * children: Map<string, TreeNode>
48
+ * }} TreeNode
49
+ */
50
+
51
+ /** @returns {TreeNode} */
52
+ function makeNode() {
53
+ return { threeType: `import("three").Object3D`, components: new Map(), children: new Map() };
54
+ }
55
+
56
+ /**
57
+ * Build a tree from BindingEntry[]. Each entry's nodePath (e.g. "UI/Camera/Target")
58
+ * defines where in the tree the node lives.
59
+ *
60
+ * @param {BindingEntry[]} entries
61
+ * @returns {Map<string, TreeNode>} Root-level nodes
62
+ */
63
+ function buildTree(entries) {
64
+ /** @type {Map<string, TreeNode>} */
65
+ const roots = new Map();
66
+
67
+ for (const { nodePath, componentName, fieldTypes, isEngineComponent, nodeThreeType } of entries) {
68
+ const parts = nodePath.split("/").filter(Boolean);
69
+ if (parts.length === 0) continue;
70
+
71
+ let map = roots;
72
+ /** @type {TreeNode | null} */
73
+ let node = null;
74
+ for (const part of parts) {
75
+ if (!map.has(part)) map.set(part, makeNode());
76
+ node = /** @type {TreeNode} */ (map.get(part));
77
+ map = node.children;
78
+ }
79
+ if (!node) continue;
80
+
81
+ node.threeType = nodeThreeType;
82
+
83
+ if (componentName) {
84
+ if (!node.components.has(componentName)) {
85
+ node.components.set(componentName, { isEngineComponent, fields: new Map() });
86
+ }
87
+ const comp = /** @type {{ isEngineComponent: boolean, fields: Map<string, Set<string>> }} */ (node.components.get(componentName));
88
+ for (const [field, tsType] of Object.entries(fieldTypes)) {
89
+ if (!comp.fields.has(field)) comp.fields.set(field, new Set());
90
+ /** @type {Set<string>} */ (comp.fields.get(field)).add(tsType);
91
+ }
92
+ }
93
+ }
94
+
95
+ return roots;
96
+ }
97
+
98
+ /**
99
+ * Compute the human-readable summary for a node (component names or Three.js type).
100
+ * @param {TreeNode} node
101
+ * @returns {string}
102
+ */
103
+ function nodeSummary(node) {
104
+ const threeShortType = node.threeType.replace(/import\("three"\)\./g, "THREE.");
105
+ const compNames = Array.from(node.components.keys()).filter(n => n !== "").sort();
106
+ return compNames.length > 0 ? compNames.join(", ") : threeShortType;
107
+ }
108
+
109
+ /**
110
+ * Recursively collect type alias declarations for all nodes in the tree.
111
+ * Emits one `type $Alias = { ... }` per node, with JSDoc summary.
112
+ *
113
+ * @param {string} _key
114
+ * @param {TreeNode} node
115
+ * @param {string} nodePath Full path e.g. "Minimal/Cube"
116
+ * @param {string[]} aliases Accumulator — lines pushed in bottom-up order
117
+ */
118
+ function collectTypeAliases(_key, node, nodePath, aliases) {
119
+ // Recurse into children first (bottom-up so aliases are defined before use)
120
+ for (const [childKey, childNode] of Array.from(node.children.entries()).sort(([a], [b]) => a.localeCompare(b))) {
121
+ collectTypeAliases(childKey, childNode, `${nodePath}/${childKey}`, aliases);
122
+ }
123
+
124
+ const threeShortType = node.threeType.replace(/import\("three"\)\./g, "THREE.");
125
+ const compEntries = Array.from(node.components.entries())
126
+ .filter(([n]) => n !== "")
127
+ .sort(([a], [b]) => a.localeCompare(b));
128
+ const summary = nodeSummary(node);
129
+
130
+ const alias = typeAliasName(nodePath);
131
+
132
+ // Emit as an interface so each child property can carry JSDoc
133
+ const lines = [];
134
+ lines.push(`/** \`${nodePath}\` — ${summary} */`);
135
+ lines.push(`interface ${alias} {`);
136
+ lines.push(` $object: ${threeShortType};`);
137
+
138
+ if (compEntries.length > 0) {
139
+ const compParts = compEntries.map(([compName, { isEngineComponent, fields }]) => {
140
+ if (isEngineComponent) {
141
+ return `${propKey(compName)}: NE.${compName}`;
142
+ } else {
143
+ const fieldParts = [`enabled: boolean`];
144
+ for (const [field, types] of Array.from(fields.entries()).sort(([a], [b]) => a.localeCompare(b))) {
145
+ fieldParts.push(`${field}: ${Array.from(types).join(" | ")}`);
146
+ }
147
+ return `${propKey(compName)}: { ${fieldParts.join("; ")} }`;
148
+ }
149
+ });
150
+ lines.push(` /** Needle Engine components on this node. Access via \`getComponent()\`, \`addComponent()\`, or \`findObjectOfType()\` / \`findObjectsOfType()\`. */`);
151
+ lines.push(` $components: { ${compParts.join("; ")} };`);
152
+ }
153
+
154
+ for (const [childKey, childNode] of Array.from(node.children.entries()).sort(([a], [b]) => a.localeCompare(b))) {
155
+ const childAlias = typeAliasName(`${nodePath}/${childKey}`);
156
+ const childSummary = nodeSummary(childNode);
157
+ lines.push(` /** ${childSummary} */`);
158
+ lines.push(` ${propKey(childKey)}: ${childAlias};`);
159
+ }
160
+
161
+ lines.push(`}`);
162
+
163
+ aliases.push(lines.join("\n"));
164
+ }
165
+
166
+ /**
167
+ * Recursively render a tree node as indented JSDoc lines.
168
+ * @param {string} key
169
+ * @param {TreeNode} node
170
+ * @param {number} depth
171
+ * @param {string[]} lines
172
+ */
173
+ /** @param {string} threeType @returns {string} */
174
+ function nodeEmoji(threeType) {
175
+ if (threeType.includes("Scene")) return "🎬";
176
+ if (threeType.includes("SkinnedMesh")) return "🦴";
177
+ if (threeType.includes("Mesh")) return "⊞";
178
+ if (threeType.includes("PerspectiveCamera") || threeType.includes("OrthographicCamera") || threeType.includes("Camera")) return "👁";
179
+ if (threeType.includes("Light")) return "💡";
180
+ return "";
181
+ }
182
+
183
+ function buildTreeDoc(key, node, depth, lines) {
184
+ const indent = " ".repeat(depth);
185
+ const hasComponents = Array.from(node.components.keys()).some(n => n !== "");
186
+ const summary = nodeSummary(node);
187
+ const emoji = nodeEmoji(node.threeType);
188
+ const prefix = emoji ? `${emoji} ` : "";
189
+ // Bold + components for nodes that have something actionable; plain name for empty containers
190
+ const label = hasComponents ? `${prefix}**${key}** — ${summary}` : `${prefix}${key}`;
191
+ lines.push(` * ${indent}- ${label}`);
192
+ for (const [childKey, childNode] of Array.from(node.children.entries()).sort(([a], [b]) => a.localeCompare(b))) {
193
+ buildTreeDoc(childKey, childNode, depth + 1, lines);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Generate the `needle-bindings.gen.d.ts` content from binding entries.
199
+ *
200
+ * @param {BindingEntry[]} entries
201
+ * @returns {string}
202
+ */
203
+ export function generateDts(entries) {
204
+ /** @type {Map<string, BindingEntry[]>} */
205
+ const byGlb = new Map();
206
+ /** @type {Map<string, string>} glbKey → source path/URL */
207
+ const glbSrcMap = new Map();
208
+ /** @type {Map<string, Set<string>>} glbKey → set of source files referencing it */
209
+ const glbSourceFilesMap = new Map();
210
+ for (const entry of entries) {
211
+ const k = entry.glbKey;
212
+ if (!byGlb.has(k)) byGlb.set(k, []);
213
+ /** @type {BindingEntry[]} */ (byGlb.get(k)).push(entry);
214
+ if (entry.glbSrc && !glbSrcMap.has(k)) glbSrcMap.set(k, entry.glbSrc);
215
+ if (entry.glbSourceFiles?.length) {
216
+ if (!glbSourceFilesMap.has(k)) glbSourceFilesMap.set(k, new Set());
217
+ for (const sf of entry.glbSourceFiles) /** @type {Set<string>} */ (glbSourceFilesMap.get(k)).add(sf);
218
+ }
219
+ }
220
+
221
+ const header = [
222
+ `// Auto-generated by @needle-tools/engine — do not edit`,
223
+ `// Regenerated on each vite dev-server start and GLB change.`,
224
+ `// Augments the base "needle-bindings" declaration in @needle-tools/engine.`,
225
+ `import type * as NE from "../../lib/needle-engine.js";`,
226
+ `import type * as THREE from "three";`,
227
+ ];
228
+
229
+ /** @type {string[]} */
230
+ const aliases = [];
231
+ /** @type {string[]} */
232
+ const interfaceLines = [
233
+ `declare module "needle-bindings" {`,
234
+ ` interface SceneData {`,
235
+ ];
236
+
237
+ for (const [glbKey, glbEntries] of Array.from(byGlb.entries()).sort(([a], [b]) => a.localeCompare(b))) {
238
+ const roots = buildTree(glbEntries);
239
+ if (roots.size === 0) continue;
240
+
241
+ // Collect type aliases for all nodes in this GLB
242
+ for (const [rootKey, rootNode] of Array.from(roots.entries()).sort(([a], [b]) => a.localeCompare(b))) {
243
+ collectTypeAliases(rootKey, rootNode, `${glbKey}/${rootKey}`, aliases);
244
+ }
245
+
246
+ // Emit SceneData property references with hierarchy JSDoc
247
+ const glbSrc = glbSrcMap.get(glbKey);
248
+ const sourceFiles = glbSourceFilesMap.get(glbKey);
249
+ const treeDocLines = [` * GLB/glTF scene file`];
250
+ const glbSrcLink = glbSrc
251
+ ? (glbSrc.startsWith("http://") || glbSrc.startsWith("https://")
252
+ ? ` — [${glbSrc}](${addViewParam(glbSrc)})`
253
+ : ` — \`${glbSrc}\``)
254
+ : "";
255
+ treeDocLines.push(` * \`${glbKey}\`${glbSrcLink}`);
256
+ if (sourceFiles?.size) {
257
+ treeDocLines.push(` *`);
258
+ treeDocLines.push(` * **Referenced from:**`);
259
+ for (const sf of Array.from(sourceFiles).sort()) {
260
+ treeDocLines.push(` * - \`${sf}\``);
261
+ }
262
+ }
263
+ treeDocLines.push(` *`);
264
+ treeDocLines.push(` * ---`);
265
+ treeDocLines.push(` *`);
266
+ for (const [rootKey, rootNode] of Array.from(roots.entries()).sort(([a], [b]) => a.localeCompare(b))) {
267
+ buildTreeDoc(rootKey, rootNode, 0, treeDocLines);
268
+ }
269
+ interfaceLines.push(` /**\n ${treeDocLines.join("\n ")}\n */`);
270
+ interfaceLines.push(` $${glbKey}: {`);
271
+ for (const [rootKey, rootNode] of Array.from(roots.entries()).sort(([a], [b]) => a.localeCompare(b))) {
272
+ const alias = typeAliasName(`${glbKey}/${rootKey}`);
273
+ const summary = nodeSummary(rootNode);
274
+ interfaceLines.push(` /** ${summary} */`);
275
+ interfaceLines.push(` ${propKey(rootKey)}: ${alias};`);
276
+ }
277
+ interfaceLines.push(` };`);
278
+ }
279
+
280
+ interfaceLines.push(` }`);
281
+ interfaceLines.push(`}`);
282
+
283
+ return [...header, ``, ...aliases, ``, ...interfaceLines, ``].join("\n");
284
+ }
285
+
286
+ /**
287
+ * Generate VS Code HTML custom data JSON for `data-bind-needle` completions.
288
+ *
289
+ * @param {BindingEntry[]} entries
290
+ * @returns {string} JSON string
291
+ */
292
+ export function generateHtmlCustomData(entries) {
293
+ const pairs = Array.from(
294
+ new Set(entries.filter(e => e.componentName).map(e => `${e.nodePath}/${e.componentName}`))
295
+ ).sort();
296
+
297
+ const values = pairs.map(pair => {
298
+ const slash = pair.lastIndexOf("/");
299
+ const nodePath = pair.slice(0, slash);
300
+ const compName = pair.slice(slash + 1);
301
+ return {
302
+ name: pair,
303
+ description: `Bind to the **${compName}** component on node \`${nodePath}\`.`,
304
+ };
305
+ });
306
+
307
+ const data = {
308
+ version: 1.1,
309
+ globalAttributes: [
310
+ {
311
+ name: "data-bind-needle",
312
+ description: {
313
+ kind: "markdown",
314
+ value: [
315
+ "Binds this HTML element to a Needle Engine scene component.",
316
+ "",
317
+ "**Format:** `NodePath/ComponentName`",
318
+ "",
319
+ "The Needle Engine runtime will associate this element with the specified",
320
+ "component instance in the live scene graph.",
321
+ "",
322
+ "**Example:**",
323
+ "```html",
324
+ '<div data-bind-needle="Camera/OrbitControls">',
325
+ "```",
326
+ ].join("\n"),
327
+ },
328
+ values,
329
+ },
330
+ ],
331
+ };
332
+
333
+ return JSON.stringify(data, null, 2) + "\n";
334
+ }
@@ -0,0 +1,99 @@
1
+ // @ts-check
2
+ /**
3
+ * Scans GLB/glTF files and returns structured binding data.
4
+ *
5
+ * Combines file discovery, JSON reading, component extraction, and manifest
6
+ * type resolution into the `BindingEntry[]` array consumed by codegen.
7
+ */
8
+
9
+ import { resolveEntrypointGlbs, collectSceneFiles, glbFriendlyName } from './glb.discovery.js';
10
+ import { readGlbJsonChunk, readGltfJsonFile, readRemoteGlbJsonChunk } from './glb.reader.js';
11
+ import { extractComponentBindings, inferTsType } from './glb.extractor.js';
12
+ import { componentsManifest } from './manifest.types.js';
13
+ import { needleLog } from '../vite/logging.js';
14
+
15
+ const PLUGIN = "needle:dts-generator";
16
+
17
+ /**
18
+ * @typedef {Object} BindingEntry
19
+ * @property {string} nodeName
20
+ * @property {string} nodePath Full hierarchy path e.g. "Scene/Cube/Child_Of_Cube"
21
+ * @property {string} componentName
22
+ * @property {Record<string, string>} fieldTypes field name → TS type string
23
+ * @property {boolean} isEngineComponent true if the component exists in components.needle.json
24
+ * @property {string} nodeThreeType Three.js type of the parent node (e.g. `import("three").Mesh`)
25
+ * @property {string} glbKey Friendly identifier derived from the GLB name (e.g. "myScene", "MaterialXNodes")
26
+ * @property {string} glbSrc Project-relative path or URL of the GLB file
27
+ * @property {string[]} [glbSourceFiles] Source files (relative to project root) that reference this GLB
28
+ */
29
+
30
+ /**
31
+ * Scan GLB/glTF files and return structured binding data.
32
+ * Uses entrypoint GLBs (from index.html, gen.js, or source files) when available,
33
+ * otherwise falls back to scanning all GLBs in assetsDir.
34
+ *
35
+ * @param {string} assetsDir Absolute path to the assets directory
36
+ * @param {string} [projectRoot] Absolute path to the project root (enables entrypoint detection)
37
+ * @param {string} [codegenDir] Absolute path to the codegen directory
38
+ * @returns {Promise<BindingEntry[]>}
39
+ */
40
+ export async function scanBindings(assetsDir, projectRoot, codegenDir) {
41
+ /** @type {Array<{path: string, type: "glb"|"gltf", remote?: boolean, key?: string}>} */
42
+ const files = /** @type {any} */ ((projectRoot ? resolveEntrypointGlbs(projectRoot, assetsDir, codegenDir) : null)
43
+ ?? collectSceneFiles(assetsDir));
44
+
45
+ needleLog(PLUGIN, `Discovered ${files.length} GLB(s):\n${files.map(f => ` ${f.path}`).join("\n")}`);
46
+
47
+ /** @type {BindingEntry[]} */
48
+ const entries = [];
49
+
50
+ for (const file of files) {
51
+ let json = /** @type {Record<string, unknown> | null} */ (null);
52
+ let contentDispositionFilename = /** @type {string | null} */ (null);
53
+
54
+ if (file.remote) {
55
+ needleLog(PLUGIN, `Fetching remote GLB: ${file.path}`);
56
+ const result = await readRemoteGlbJsonChunk(file.path);
57
+ if (!result) { needleLog(PLUGIN, `Skipped (fetch failed): ${file.path}`, "warn"); continue; }
58
+ json = result.json;
59
+ contentDispositionFilename = result.filename;
60
+ needleLog(PLUGIN, `Remote GLB ok — Content-Disposition: ${contentDispositionFilename ?? "(none)"}`);
61
+ } else {
62
+ json = file.type === "glb" ? readGlbJsonChunk(file.path) : readGltfJsonFile(file.path);
63
+ }
64
+ if (!json) continue;
65
+
66
+ // Derive a friendly identifier for this GLB (used as SceneData key).
67
+ // For local files: basename without extension.
68
+ // For remote: Content-Disposition filename > last non-generic URL segment.
69
+ const localPathForName = file.remote ? file.path : (
70
+ projectRoot
71
+ ? file.path.replace(projectRoot + "/", "").replace(projectRoot + "\\", "")
72
+ : file.path
73
+ );
74
+ const glbKey = glbFriendlyName(localPathForName, contentDispositionFilename);
75
+
76
+ for (const { nodeName, nodePath, componentName, fields, nodeThreeType } of extractComponentBindings(json)) {
77
+ /** @type {Record<string, string>} */
78
+ const fieldTypes = {};
79
+ if (componentName) {
80
+ const manifestFields = componentsManifest.get(componentName);
81
+ if (manifestFields) {
82
+ for (const [k, tsType] of manifestFields) {
83
+ fieldTypes[k] = tsType;
84
+ }
85
+ } else {
86
+ for (const [k, v] of Object.entries(fields)) {
87
+ fieldTypes[k] = inferTsType(v);
88
+ }
89
+ }
90
+ }
91
+ const isEngineComponent = componentName ? componentsManifest.has(componentName) : false;
92
+ const glbSrc = file.remote ? file.path : localPathForName;
93
+ const glbSourceFiles = /** @type {string[] | undefined} */ (/** @type {any} */ (file).sourceFiles);
94
+ entries.push({ nodeName, nodePath, componentName, fieldTypes, isEngineComponent, nodeThreeType, glbKey, glbSrc, glbSourceFiles });
95
+ }
96
+ }
97
+
98
+ return entries;
99
+ }
@@ -0,0 +1,59 @@
1
+ // @ts-check
2
+ /**
3
+ * File writer — the only module that performs I/O writes.
4
+ *
5
+ * Orchestrates scanning, codegen, and writes the output files.
6
+ * Returns false if content was already up-to-date (no write performed).
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
10
+ import { join, dirname } from 'path';
11
+ import { scanBindings } from './dts.scan.js';
12
+ import { generateDts, generateHtmlCustomData } from './dts.codegen.js';
13
+
14
+ /**
15
+ * Scan assets and write `needle-bindings.d.ts` and `needle-html-data.json`
16
+ * to the output directory.
17
+ * Returns the number of bindings written, or `false` if content was already up-to-date.
18
+ *
19
+ * @param {object} opts
20
+ * @param {string} opts.assetsDir Absolute path to assets directory
21
+ * @param {string} opts.outputPath Absolute path to write needle-bindings.d.ts
22
+ * @param {string} [opts.projectRoot] Project root — enables entrypoint GLB detection
23
+ * @param {string} [opts.codegenDir] Codegen directory — used to find gen.js and write needle-html-data.json (no binding copy written here)
24
+ * @returns {Promise<number | false>}
25
+ */
26
+ export async function generateBindingsDts({ assetsDir, outputPath, projectRoot, codegenDir }) {
27
+ const entries = await scanBindings(assetsDir, projectRoot, codegenDir);
28
+ const content = generateDts(entries);
29
+ const htmlContent = generateHtmlCustomData(entries);
30
+
31
+ mkdirSync(dirname(outputPath), { recursive: true });
32
+
33
+ let dtsChanged = false;
34
+ try {
35
+ const existing = existsSync(outputPath) ? readFileSync(outputPath, "utf8") : "";
36
+ if (existing !== content) {
37
+ writeFileSync(outputPath, content, "utf8");
38
+ dtsChanged = true;
39
+ }
40
+ } catch (_e) {
41
+ writeFileSync(outputPath, content, "utf8");
42
+ dtsChanged = true;
43
+ }
44
+
45
+ const htmlDataPath = join(dirname(outputPath), "needle-html-data.json");
46
+ try {
47
+ const existingHtml = existsSync(htmlDataPath) ? readFileSync(htmlDataPath, "utf8") : "";
48
+ if (existingHtml !== htmlContent) {
49
+ writeFileSync(htmlDataPath, htmlContent, "utf8");
50
+ }
51
+ } catch (_e) {
52
+ writeFileSync(htmlDataPath, htmlContent, "utf8");
53
+ }
54
+
55
+ if (!dtsChanged) return false;
56
+ const nodeCount = new Set(entries.map(e => e.nodeName)).size;
57
+ const bindingCount = new Set(entries.filter(e => e.componentName).map(e => `${e.nodeName}/${e.componentName}`)).size;
58
+ return bindingCount > 0 ? bindingCount : nodeCount;
59
+ }