@needle-tools/engine 5.0.3 → 5.1.0-alpha.1

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 (173) hide show
  1. package/CHANGELOG.md +31 -4
  2. package/README.md +6 -7
  3. package/components.needle.json +1 -1
  4. package/dist/{needle-engine.bundle-BXk8jYW3.js → needle-engine.bundle-BGyKqxBH.js} +12394 -11786
  5. package/dist/needle-engine.bundle-CiYtOO2O.min.js +1732 -0
  6. package/dist/needle-engine.bundle-DzVx9Z8D.umd.cjs +1732 -0
  7. package/dist/needle-engine.d.ts +660 -63
  8. package/dist/needle-engine.js +579 -566
  9. package/dist/needle-engine.min.js +1 -1
  10. package/dist/needle-engine.umd.cjs +1 -1
  11. package/dist/{vendor-vHLk8sXu.js → vendor-CAcsI0eU.js} +116 -115
  12. package/dist/{vendor-CntUvmJu.umd.cjs → vendor-CEM38hLE.umd.cjs} +2 -2
  13. package/dist/{vendor-DPbfJJ4d.min.js → vendor-HRlxIBga.min.js} +2 -2
  14. package/lib/engine/api.d.ts +2 -0
  15. package/lib/engine/api.js +2 -0
  16. package/lib/engine/api.js.map +1 -1
  17. package/lib/engine/engine_addressables.js +5 -1
  18. package/lib/engine/engine_addressables.js.map +1 -1
  19. package/lib/engine/engine_animation.d.ts +14 -7
  20. package/lib/engine/engine_animation.js +49 -9
  21. package/lib/engine/engine_animation.js.map +1 -1
  22. package/lib/engine/engine_components.js +33 -4
  23. package/lib/engine/engine_components.js.map +1 -1
  24. package/lib/engine/engine_context.d.ts +7 -2
  25. package/lib/engine/engine_context.js +10 -2
  26. package/lib/engine/engine_context.js.map +1 -1
  27. package/lib/engine/engine_gameobject.d.ts +4 -0
  28. package/lib/engine/engine_gameobject.js.map +1 -1
  29. package/lib/engine/engine_init.js +4 -0
  30. package/lib/engine/engine_init.js.map +1 -1
  31. package/lib/engine/engine_input.js +4 -1
  32. package/lib/engine/engine_input.js.map +1 -1
  33. package/lib/engine/engine_materialpropertyblock.js +0 -19
  34. package/lib/engine/engine_materialpropertyblock.js.map +1 -1
  35. package/lib/engine/engine_networking.d.ts +11 -8
  36. package/lib/engine/engine_networking.js +43 -26
  37. package/lib/engine/engine_networking.js.map +1 -1
  38. package/lib/engine/engine_networking_instantiate.d.ts +100 -5
  39. package/lib/engine/engine_networking_instantiate.js +150 -16
  40. package/lib/engine/engine_networking_instantiate.js.map +1 -1
  41. package/lib/engine/engine_networking_prefabs.d.ts +59 -0
  42. package/lib/engine/engine_networking_prefabs.js +67 -0
  43. package/lib/engine/engine_networking_prefabs.js.map +1 -0
  44. package/lib/engine/engine_physics_rapier.d.ts +3 -0
  45. package/lib/engine/engine_physics_rapier.js +13 -9
  46. package/lib/engine/engine_physics_rapier.js.map +1 -1
  47. package/lib/engine/engine_utils.js +2 -2
  48. package/lib/engine/engine_utils.js.map +1 -1
  49. package/lib/engine/postprocessing/api.d.ts +2 -0
  50. package/lib/engine/postprocessing/api.js +2 -0
  51. package/lib/engine/postprocessing/api.js.map +1 -0
  52. package/lib/engine/postprocessing/index.d.ts +2 -0
  53. package/lib/engine/postprocessing/index.js +2 -0
  54. package/lib/engine/postprocessing/index.js.map +1 -0
  55. package/lib/engine/postprocessing/postprocessing.d.ts +83 -0
  56. package/lib/engine/postprocessing/postprocessing.js +280 -0
  57. package/lib/engine/postprocessing/postprocessing.js.map +1 -0
  58. package/lib/engine/postprocessing/types.d.ts +39 -0
  59. package/lib/engine/postprocessing/types.js +2 -0
  60. package/lib/engine/postprocessing/types.js.map +1 -0
  61. package/lib/engine/webcomponents/WebXRButtons.js +17 -3
  62. package/lib/engine/webcomponents/WebXRButtons.js.map +1 -1
  63. package/lib/engine/xr/NeedleXRSession.d.ts +2 -0
  64. package/lib/engine/xr/NeedleXRSession.js +47 -14
  65. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  66. package/lib/engine/xr/events.d.ts +30 -3
  67. package/lib/engine/xr/events.js +38 -0
  68. package/lib/engine/xr/events.js.map +1 -1
  69. package/lib/engine/xr/init.d.ts +4 -0
  70. package/lib/engine/xr/init.js +43 -0
  71. package/lib/engine/xr/init.js.map +1 -0
  72. package/lib/engine-components/AnimationUtils.d.ts +4 -1
  73. package/lib/engine-components/AnimationUtils.js +7 -19
  74. package/lib/engine-components/AnimationUtils.js.map +1 -1
  75. package/lib/engine-components/AnimatorController.d.ts +135 -2
  76. package/lib/engine-components/AnimatorController.js +216 -13
  77. package/lib/engine-components/AnimatorController.js.map +1 -1
  78. package/lib/engine-components/SeeThrough.d.ts +0 -2
  79. package/lib/engine-components/SeeThrough.js +0 -89
  80. package/lib/engine-components/SeeThrough.js.map +1 -1
  81. package/lib/engine-components/SyncedRoom.d.ts +4 -0
  82. package/lib/engine-components/SyncedRoom.js +23 -8
  83. package/lib/engine-components/SyncedRoom.js.map +1 -1
  84. package/lib/engine-components/SyncedTransform.js +5 -5
  85. package/lib/engine-components/SyncedTransform.js.map +1 -1
  86. package/lib/engine-components/Voip.d.ts +46 -0
  87. package/lib/engine-components/Voip.js +126 -2
  88. package/lib/engine-components/Voip.js.map +1 -1
  89. package/lib/engine-components/api.d.ts +1 -0
  90. package/lib/engine-components/api.js +1 -0
  91. package/lib/engine-components/api.js.map +1 -1
  92. package/lib/engine-components/codegen/components.d.ts +1 -0
  93. package/lib/engine-components/codegen/components.js +1 -0
  94. package/lib/engine-components/codegen/components.js.map +1 -1
  95. package/lib/engine-components/postprocessing/Effects/Tonemapping.d.ts +5 -2
  96. package/lib/engine-components/postprocessing/Effects/Tonemapping.js +11 -18
  97. package/lib/engine-components/postprocessing/Effects/Tonemapping.js.map +1 -1
  98. package/lib/engine-components/postprocessing/PostProcessingEffect.d.ts +3 -4
  99. package/lib/engine-components/postprocessing/PostProcessingEffect.js +6 -15
  100. package/lib/engine-components/postprocessing/PostProcessingEffect.js.map +1 -1
  101. package/lib/engine-components/postprocessing/PostProcessingHandler.d.ts +2 -1
  102. package/lib/engine-components/postprocessing/PostProcessingHandler.js.map +1 -1
  103. package/lib/engine-components/postprocessing/Volume.d.ts +18 -11
  104. package/lib/engine-components/postprocessing/Volume.js +61 -140
  105. package/lib/engine-components/postprocessing/Volume.js.map +1 -1
  106. package/lib/engine-components/postprocessing/index.d.ts +1 -0
  107. package/lib/engine-components/postprocessing/index.js +1 -0
  108. package/lib/engine-components/postprocessing/index.js.map +1 -1
  109. package/lib/engine-components/postprocessing/utils.d.ts +2 -0
  110. package/lib/engine-components/postprocessing/utils.js +2 -0
  111. package/lib/engine-components/postprocessing/utils.js.map +1 -1
  112. package/lib/engine-components/ui/Canvas.js +2 -2
  113. package/lib/engine-components/ui/Canvas.js.map +1 -1
  114. package/lib/engine-components/ui/Graphic.d.ts +3 -3
  115. package/lib/engine-components/ui/Graphic.js +6 -2
  116. package/lib/engine-components/ui/Graphic.js.map +1 -1
  117. package/lib/engine-components/ui/Text.d.ts +64 -11
  118. package/lib/engine-components/ui/Text.js +154 -45
  119. package/lib/engine-components/ui/Text.js.map +1 -1
  120. package/lib/engine-components/ui/index.d.ts +1 -0
  121. package/lib/engine-components/ui/index.js +1 -0
  122. package/lib/engine-components/ui/index.js.map +1 -1
  123. package/lib/engine-components-experimental/networking/PlayerSync.d.ts +25 -3
  124. package/lib/engine-components-experimental/networking/PlayerSync.js +60 -11
  125. package/lib/engine-components-experimental/networking/PlayerSync.js.map +1 -1
  126. package/package.json +5 -4
  127. package/plugins/common/logger.js +42 -19
  128. package/plugins/vite/ai.d.ts +11 -10
  129. package/plugins/vite/ai.js +305 -31
  130. package/plugins/vite/logger.client.js +4 -3
  131. package/src/engine/api.ts +3 -0
  132. package/src/engine/engine_addressables.ts +4 -1
  133. package/src/engine/engine_animation.ts +47 -9
  134. package/src/engine/engine_components.ts +36 -7
  135. package/src/engine/engine_context.ts +11 -2
  136. package/src/engine/engine_gameobject.ts +5 -0
  137. package/src/engine/engine_init.ts +4 -0
  138. package/src/engine/engine_input.ts +2 -1
  139. package/src/engine/engine_materialpropertyblock.ts +0 -19
  140. package/src/engine/engine_networking.ts +46 -23
  141. package/src/engine/engine_networking_instantiate.ts +160 -18
  142. package/src/engine/engine_networking_prefabs.ts +80 -0
  143. package/src/engine/engine_physics_rapier.ts +14 -9
  144. package/src/engine/engine_utils.ts +2 -2
  145. package/src/engine/postprocessing/api.ts +2 -0
  146. package/src/engine/postprocessing/index.ts +2 -0
  147. package/src/engine/postprocessing/postprocessing.ts +322 -0
  148. package/src/engine/postprocessing/types.ts +43 -0
  149. package/src/engine/webcomponents/WebXRButtons.ts +21 -4
  150. package/src/engine/xr/NeedleXRSession.ts +55 -20
  151. package/src/engine/xr/events.ts +44 -1
  152. package/src/engine/xr/init.ts +49 -0
  153. package/src/engine-components/AnimationUtils.ts +7 -17
  154. package/src/engine-components/AnimatorController.ts +288 -18
  155. package/src/engine-components/SeeThrough.ts +0 -116
  156. package/src/engine-components/SyncedRoom.ts +28 -9
  157. package/src/engine-components/SyncedTransform.ts +5 -5
  158. package/src/engine-components/Voip.ts +129 -2
  159. package/src/engine-components/api.ts +1 -0
  160. package/src/engine-components/codegen/components.ts +1 -0
  161. package/src/engine-components/postprocessing/Effects/Tonemapping.ts +16 -24
  162. package/src/engine-components/postprocessing/PostProcessingEffect.ts +9 -16
  163. package/src/engine-components/postprocessing/PostProcessingHandler.ts +2 -1
  164. package/src/engine-components/postprocessing/Volume.ts +72 -163
  165. package/src/engine-components/postprocessing/index.ts +1 -0
  166. package/src/engine-components/postprocessing/utils.ts +2 -0
  167. package/src/engine-components/ui/Canvas.ts +2 -2
  168. package/src/engine-components/ui/Graphic.ts +7 -3
  169. package/src/engine-components/ui/Text.ts +170 -52
  170. package/src/engine-components/ui/index.ts +2 -1
  171. package/src/engine-components-experimental/networking/PlayerSync.ts +64 -11
  172. package/dist/needle-engine.bundle-CNH61kLA.umd.cjs +0 -1730
  173. package/dist/needle-engine.bundle-Dvh1jROn.min.js +0 -1730
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2
- import { dirname, join } from "path";
1
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "fs";
2
+ import { dirname, join, relative } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { needleLog } from "./logging.js";
5
5
 
@@ -8,20 +8,49 @@ const __dirname = dirname(__filename);
8
8
 
9
9
  const pluginName = "needle-ai";
10
10
 
11
+ /**
12
+ * Supported AI coding agents.
13
+ * Each entry defines how to detect the agent and how to write its skill file.
14
+ * `detect` is the directory that must exist in the project root.
15
+ * `write(cwd, canonicalDir, content)` installs the skill in the agent's native format.
16
+ *
17
+ * The `.agents/` entry is special: it is the canonical location where SKILL.md
18
+ * and downloaded reference files are written directly. All other agents symlink
19
+ * their skill directory to `.agents/skills/needle-engine/`.
20
+ */
21
+ const agents = [
22
+ {
23
+ name: "Claude Code",
24
+ detect: ".claude",
25
+ write: (cwd, canonicalDir, content) => symlinkSkillDir(join(cwd, ".claude"), canonicalDir),
26
+ },
27
+ {
28
+ name: "GitHub Copilot",
29
+ detect: ".github",
30
+ write: (cwd, canonicalDir, content) => symlinkSkillDir(join(cwd, ".github"), canonicalDir),
31
+ },
32
+ {
33
+ name: "Cursor",
34
+ detect: ".cursor",
35
+ write: (cwd, canonicalDir, content) => writeCursorRule(cwd, canonicalDir, content),
36
+ },
37
+ ];
38
+
11
39
  /**
12
40
  * Needle Engine AI skill installer.
13
41
  *
14
- * Writes a Needle Engine skill to `<dir>/skills/needle-engine/SKILL.md`
15
- * for each supported AI agent directory (`.claude/`, `.github/`, `.agents/`).
16
- * Both Claude Code and GitHub Copilot auto-load skills based on their
17
- * description frontmatter, so the AI agent will automatically have Needle
18
- * Engine context when working in the project.
42
+ * Auto-detects AI coding agents by checking for their config directories
43
+ * in the project root, then writes the Needle Engine skill in each agent's
44
+ * native format.
45
+ *
46
+ * Supported agents: Claude Code, GitHub Copilot, Cursor, Codex / universal.
19
47
  *
20
- * The skill is only written if at least one of the supported directories
21
- * already exists in the project root (i.e. the developer is already using
22
- * an AI coding agent).
23
- * Old skill files are always overwritten so the skill stays up to date with
24
- * the engine version.
48
+ * `.agents/skills/needle-engine/` is always created as the canonical skill
49
+ * location. SKILL.md and downloaded reference files live there. Other agents
50
+ * symlink to it to avoid duplication.
51
+ *
52
+ * Remote reference files (API docs, templates, etc.) linked from SKILL.md are
53
+ * downloaded at install time and stored locally with relative paths.
25
54
  *
26
55
  * @param {"build" | "serve"} command
27
56
  * @param {{} | undefined | null} config
@@ -32,34 +61,279 @@ export function needleAI(command, config, userSettings) {
32
61
  return {
33
62
  name: pluginName,
34
63
  enforce: "pre",
35
- buildStart() {
36
- installClaudeSkill();
64
+ async buildStart() {
65
+ await installSkills();
37
66
  },
38
- configureServer() {
39
- installClaudeSkill();
67
+ async configureServer() {
68
+ await installSkills();
40
69
  },
41
70
  };
42
71
  }
43
72
 
44
- function writeSkill(claudeDir) {
45
- const skillDir = join(claudeDir, "skills", "needle-engine");
46
- if (!existsSync(skillDir)) {
47
- mkdirSync(skillDir, { recursive: true });
48
- }
49
- const skillPath = join(skillDir, "SKILL.md");
73
+ /** Read the SKILL.md template shipped with the engine. */
74
+ function getSkillContent() {
50
75
  const templatePath = join(__dirname, "../../SKILL.md");
51
- const content = readFileSync(templatePath, "utf8");
52
- writeFileSync(skillPath, content, "utf8");
53
- return skillPath;
76
+ return readFileSync(templatePath, "utf8");
77
+ }
78
+
79
+ /**
80
+ * Extract the body of a SKILL.md file (everything after the YAML frontmatter).
81
+ * Returns the full content if no frontmatter is found.
82
+ */
83
+ function stripFrontmatter(content) {
84
+ const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
85
+ return match ? match[1].trimStart() : content;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Remote reference downloading
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Extract markdown links to raw.githubusercontent.com files from content.
94
+ * Returns an array of { fullMatch, linkText, url, subdir, filename, localRelPath }.
95
+ */
96
+ function extractRemoteRefs(content) {
97
+ const pattern = /\[([^\]]*)\]\((https:\/\/raw\.githubusercontent\.com\/[^)]+)\)/g;
98
+ const refs = [];
99
+ let match;
100
+ while ((match = pattern.exec(content)) !== null) {
101
+ const url = match[2];
102
+ const urlPath = new URL(url).pathname;
103
+ const segments = urlPath.split("/").filter(Boolean);
104
+ const filename = segments[segments.length - 1];
105
+ const subdir = segments[segments.length - 2] || "";
106
+ refs.push({
107
+ fullMatch: match[0],
108
+ linkText: match[1],
109
+ url,
110
+ subdir,
111
+ filename,
112
+ localRelPath: `./${subdir}/${filename}`,
113
+ });
114
+ }
115
+ return refs;
54
116
  }
55
117
 
56
- function installClaudeSkill() {
118
+ /**
119
+ * Download a single remote file. Returns text on success, null on failure.
120
+ * @param {{ fullMatch: string, linkText: string, url: string, subdir: string, filename: string, localRelPath: string }} ref
121
+ * @param {number} timeoutMs
122
+ * @returns {Promise<string | null>}
123
+ */
124
+ async function downloadRef(ref, timeoutMs = 5000) {
125
+ try {
126
+ const controller = new AbortController();
127
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
128
+ const response = await fetch(ref.url, { signal: controller.signal });
129
+ clearTimeout(timer);
130
+ if (!response.ok) return null;
131
+ return await response.text();
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Download all remote references found in content. Returns rewritten content
140
+ * (absolute URLs → relative paths) and the downloaded file data.
141
+ * Failed downloads keep their original absolute URL.
142
+ */
143
+ async function downloadAndRewriteRefs(content) {
144
+ const refs = extractRemoteRefs(content);
145
+ if (refs.length === 0) return { rewrittenContent: content, downloadedFiles: [] };
146
+
147
+ if (typeof globalThis.fetch !== "function") {
148
+ return { rewrittenContent: content, downloadedFiles: [] };
149
+ }
150
+
151
+ const results = await Promise.allSettled(
152
+ refs.map(async ref => {
153
+ const data = await downloadRef(ref);
154
+ return { ref, data };
155
+ })
156
+ );
157
+
158
+ let rewrittenContent = content;
159
+ const downloadedFiles = [];
160
+
161
+ for (const result of results) {
162
+ if (result.status === "fulfilled" && result.value.data !== null) {
163
+ const { ref, data } = result.value;
164
+ const newLink = `[${ref.linkText}](${ref.localRelPath})`;
165
+ rewrittenContent = rewrittenContent.replace(ref.fullMatch, newLink);
166
+ downloadedFiles.push({
167
+ subdir: ref.subdir,
168
+ filename: ref.filename,
169
+ data,
170
+ });
171
+ }
172
+ }
173
+
174
+ return { rewrittenContent, downloadedFiles };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Canonical skill directory (.agents/)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ /**
182
+ * Write SKILL.md and downloaded reference files into the canonical location
183
+ * at `.agents/skills/needle-engine/`. This directory is always created and
184
+ * serves as the symlink target for all other agents.
185
+ * @returns {string} The absolute path to the canonical skill directory.
186
+ */
187
+ function writeCanonicalSkillDir(cwd, content, downloadedFiles) {
188
+ const canonicalDir = join(cwd, ".agents", "skills", "needle-engine");
189
+ if (!existsSync(canonicalDir)) {
190
+ mkdirSync(canonicalDir, { recursive: true });
191
+ }
192
+
193
+ writeFileSync(join(canonicalDir, "SKILL.md"), content, "utf8");
194
+
195
+ for (const file of downloadedFiles) {
196
+ const fileDir = join(canonicalDir, file.subdir);
197
+ if (!existsSync(fileDir)) {
198
+ mkdirSync(fileDir, { recursive: true });
199
+ }
200
+ writeFileSync(join(fileDir, file.filename), file.data, "utf8");
201
+ }
202
+
203
+ return canonicalDir;
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Agent writers
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Create a symlink at `<agentDir>/skills/needle-engine` → canonical skill dir.
212
+ * If the target already exists (file, dir, or stale symlink), it is replaced.
213
+ */
214
+ function symlinkSkillDir(agentDir, canonicalDir) {
215
+ const skillsDir = join(agentDir, "skills");
216
+ const linkPath = join(skillsDir, "needle-engine");
217
+
218
+ if (!existsSync(skillsDir)) {
219
+ mkdirSync(skillsDir, { recursive: true });
220
+ }
221
+
222
+ const target = relative(skillsDir, canonicalDir);
223
+
224
+ // Remove existing entry (file, dir, or stale symlink)
225
+ try {
226
+ if (existsSync(linkPath) || lstatSync(linkPath).isSymbolicLink()) {
227
+ rmSync(linkPath, { recursive: true, force: true });
228
+ }
229
+ }
230
+ catch { /* nothing to remove */ }
231
+
232
+ try {
233
+ symlinkSync(target, linkPath, "junction");
234
+ return linkPath;
235
+ }
236
+ catch {
237
+ // Fallback: copy if symlink fails (e.g. Windows without privileges)
238
+ if (!existsSync(linkPath)) {
239
+ mkdirSync(linkPath, { recursive: true });
240
+ }
241
+ copyDirSync(canonicalDir, linkPath);
242
+ return linkPath;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Write a Cursor rule file at `.cursor/rules/needle-engine.mdc` and symlink
248
+ * the reference files directory at `.cursor/rules/needle-engine/`.
249
+ * Cursor uses its own frontmatter format (description, globs, alwaysApply).
250
+ */
251
+ function writeCursorRule(cwd, canonicalDir, content) {
252
+ const rulesDir = join(cwd, ".cursor", "rules");
253
+ if (!existsSync(rulesDir)) {
254
+ mkdirSync(rulesDir, { recursive: true });
255
+ }
256
+
257
+ // Symlink .cursor/rules/needle-engine/ → canonical dir (for reference files)
258
+ const linkPath = join(rulesDir, "needle-engine");
259
+ const target = relative(rulesDir, canonicalDir);
260
+ try {
261
+ if (existsSync(linkPath) || lstatSync(linkPath).isSymbolicLink()) {
262
+ rmSync(linkPath, { recursive: true, force: true });
263
+ }
264
+ }
265
+ catch { /* nothing to remove */ }
266
+
267
+ try {
268
+ symlinkSync(target, linkPath, "junction");
269
+ }
270
+ catch {
271
+ // Fallback: copy directly
272
+ if (!existsSync(linkPath)) {
273
+ mkdirSync(linkPath, { recursive: true });
274
+ }
275
+ copyDirSync(canonicalDir, linkPath);
276
+ }
277
+
278
+ // Write the .mdc file with adjusted relative paths
279
+ // SKILL.md uses ./references/api.md but the .mdc sits at .cursor/rules/needle-engine.mdc
280
+ // so we need ./needle-engine/references/api.md
281
+ let body = stripFrontmatter(content);
282
+ body = body.replace(/\]\(\.\/(references|templates)\//g, "](./needle-engine/$1/");
283
+
284
+ const rulePath = join(rulesDir, "needle-engine.mdc");
285
+ const cursorContent = `---
286
+ description: Needle Engine context — use when editing TypeScript components, Vite config, GLB assets, or anything related to @needle-tools/engine.
287
+ globs:
288
+ alwaysApply: false
289
+ ---
290
+ ${body}`;
291
+ writeFileSync(rulePath, cursorContent, "utf8");
292
+ return rulePath;
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Helpers
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /** Recursively copy a directory's contents. */
300
+ function copyDirSync(src, dest) {
301
+ for (const entry of readdirSync(src)) {
302
+ const srcPath = join(src, entry);
303
+ const destPath = join(dest, entry);
304
+ if (statSync(srcPath).isDirectory()) {
305
+ if (!existsSync(destPath)) mkdirSync(destPath, { recursive: true });
306
+ copyDirSync(srcPath, destPath);
307
+ }
308
+ else {
309
+ copyFileSync(srcPath, destPath);
310
+ }
311
+ }
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Main
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /** Detect agents and install the Needle Engine skill for each. */
319
+ async function installSkills() {
57
320
  const cwd = process.cwd();
58
- const dirs = [".claude", ".github", ".agents"].map(d => join(cwd, d)).filter(d => existsSync(d));
59
- if (dirs.length === 0) return; // only install if developer uses an AI coding agent
60
321
 
61
- for (const dir of dirs) {
62
- const path = writeSkill(dir);
63
- if (path) needleLog(`[${pluginName}] Installed Needle Engine skill → ${path}`);
322
+ const rawContent = getSkillContent();
323
+ const { rewrittenContent, downloadedFiles } = await downloadAndRewriteRefs(rawContent);
324
+
325
+ // Always write to .agents/ as the canonical location
326
+ const canonicalDir = writeCanonicalSkillDir(cwd, rewrittenContent, downloadedFiles);
327
+
328
+ // Symlink other detected agents to the canonical dir
329
+ const detected = agents.filter(a => existsSync(join(cwd, a.detect)));
330
+ const names = ["Codex / Universal"];
331
+ for (const agent of detected) {
332
+ const path = agent.write(cwd, canonicalDir, rewrittenContent);
333
+ if (path) names.push(agent.name);
64
334
  }
335
+
336
+ const refCount = downloadedFiles.length;
337
+ const refMsg = refCount > 0 ? ` (${refCount} reference file${refCount === 1 ? "" : "s"} downloaded)` : "";
338
+ needleLog(pluginName, `Installed for ${names.length} agent${names.length === 1 ? "" : "s"}: ${names.join(", ")}${refMsg}`);
65
339
  }
@@ -99,8 +99,9 @@ if (import.meta && "hot" in import.meta) {
99
99
  // unpatch();
100
100
  // }, 10_000);
101
101
 
102
+ const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
102
103
  const threshold = 100;
103
- const devToolsArePotentiallyOpen = window.outerHeight - window.innerHeight > threshold || window.outerWidth - window.innerWidth > threshold;
104
+ const devToolsArePotentiallyOpen = !isMobile && (window.outerHeight - window.innerHeight > threshold || window.outerWidth - window.innerWidth > threshold);
104
105
  if (devToolsArePotentiallyOpen) {
105
106
  sendLogToServer("internal", "Console logging is disabled (devtools are open)");
106
107
  }
@@ -240,8 +241,8 @@ function stringifyLog(log, seen = /** @type {Set<unknown>} */ (new Set()), depth
240
241
  const isServer = typeof window === "undefined";
241
242
  const stringify_limits = {
242
243
  string: isServer ? 100_000 : 1_000,
243
- object_keys: isServer ? 300 : 200,
244
- object_depth: isServer ? 10 : 3,
244
+ object_keys: isServer ? 10 : 10,
245
+ object_depth: isServer ? 3 : 3,
245
246
  array_items: isServer ? 2_000 : 100,
246
247
  }
247
248
 
package/src/engine/api.ts CHANGED
@@ -318,6 +318,9 @@ export * from "./engine_physics.types.js";
318
318
  /** Rapier physics engine integration */
319
319
  export * from "./engine_physics_rapier.js";
320
320
 
321
+ /** Core postprocessing stack (accessible via context.postprocessing) */
322
+ export * from "./postprocessing/api.js";
323
+
321
324
 
322
325
  // ============================================================================
323
326
  // PLAYER & VIEW MANAGEMENT
@@ -463,7 +463,10 @@ export class AssetReference {
463
463
  if (networked) {
464
464
  options.context = context;
465
465
  const prefab = this.asset;
466
- prefab.guid = this.url;
466
+ // Only set guid from URL if we have one — runtime Object3D prefabs
467
+ // (via new AssetReference({ asset: obj })) have no URL and may already
468
+ // have a user-assigned guid. Don't overwrite with empty string.
469
+ if (this.url) prefab.guid = this.url;
467
470
  const instance = syncInstantiate(prefab, options, undefined, saveOnServer);
468
471
  if (instance) {
469
472
  return instance;
@@ -2,9 +2,10 @@ import { AnimationAction, AnimationClip, AnimationMixer, KeyframeTrack, Object3D
2
2
 
3
3
  import { isDevEnvironment } from "./debug/index.js";
4
4
  import type { Context } from "./engine_context.js";
5
- import { GLTF, IAnimationComponent, Model } from "./engine_types.js";
5
+ import { IAnimationComponent, Model } from "./engine_types.js";
6
6
  import { TypeStore } from "./engine_typestore.js";
7
7
 
8
+ // #region Registry
8
9
  /**
9
10
  * Registry for animation related data. Use {@link registerAnimationMixer} to register an animation mixer instance.
10
11
  * Can be accessed from {@link Context.animations} and is used internally e.g. when exporting GLTF files.
@@ -52,10 +53,11 @@ export class AnimationsRegistry {
52
53
  }
53
54
 
54
55
 
56
+ // #region Animation Utils
55
57
  /**
56
58
  * Utility class for working with animations.
57
59
  */
58
- export class AnimationUtils {
60
+ export namespace AnimationUtils {
59
61
 
60
62
  /**
61
63
  * Tests if the root object of an AnimationAction can be animated. Objects where matrixAutoUpdate or matrixWorldAutoUpdate is set to false may not animate correctly.
@@ -63,7 +65,7 @@ export class AnimationUtils {
63
65
  * @param allowLog Whether to allow logging warnings. Default is false, which only allows logging in development environments.
64
66
  * @returns True if the root object can be animated, false otherwise
65
67
  */
66
- static testIfRootCanAnimate(action: AnimationAction, allowLog?: boolean): boolean {
68
+ export function testIfRootCanAnimate(action: AnimationAction, allowLog?: boolean): boolean {
67
69
  const root = action.getRoot();
68
70
 
69
71
  if (root && (root.userData.static || root.matrixAutoUpdate === false || root.matrixWorldAutoUpdate === false)) {
@@ -79,13 +81,15 @@ export class AnimationUtils {
79
81
  * @param mixer The animation mixer to get the actions from
80
82
  * @returns The actions or null if the mixer is invalid
81
83
  */
82
- static tryGetActionsFromMixer(mixer: AnimationMixer): Array<AnimationAction> | null {
84
+
85
+ export function tryGetActionsFromMixer(mixer: AnimationMixer): Array<AnimationAction> | null {
83
86
  const actions = mixer["_actions"] as Array<AnimationAction>;
84
87
  if (!actions) return null;
85
88
  return actions;
86
89
  }
87
90
 
88
- static tryGetAnimationClipsFromObjectHierarchy(obj: Object3D, target?: Array<AnimationClip>): Array<AnimationClip> {
91
+
92
+ export function tryGetAnimationClipsFromObjectHierarchy(obj: Object3D, target?: Array<AnimationClip>): Array<AnimationClip> {
89
93
  if (!target) target = new Array<AnimationClip>();
90
94
 
91
95
  if (!obj) {
@@ -96,18 +100,49 @@ export class AnimationUtils {
96
100
  }
97
101
  if (obj.children) {
98
102
  for (const child of obj.children) {
99
- this.tryGetAnimationClipsFromObjectHierarchy(child, target);
103
+ tryGetAnimationClipsFromObjectHierarchy(child, target);
100
104
  }
101
105
  }
102
106
  return target;
103
107
  }
104
108
 
109
+
110
+ const $objectAnimationKey = Symbol("objectIsAnimatedData");
111
+
112
+ /** Internal method - This marks an object as being animated. Make sure to always call isAnimated=false if you stop animating the object
113
+ * @param obj The object to mark
114
+ * @param isAnimated Whether the object is animated or not
115
+ */
116
+ export function setObjectAnimated(obj: Object3D, animatedBy: object, isAnimated: boolean) {
117
+ if (!obj) return;
118
+ if (obj[$objectAnimationKey] === undefined) {
119
+ if (!isAnimated) return;
120
+ obj[$objectAnimationKey] = new Set<object>();
121
+ }
122
+
123
+ const set = obj[$objectAnimationKey] as Set<object>;
124
+ if (isAnimated) {
125
+ set.add(animatedBy);
126
+ }
127
+ else if (set.has(animatedBy))
128
+ set.delete(animatedBy);
129
+ }
130
+
131
+ /** Get is the object is currently animated. Currently used by the Animator to check if a timeline animationtrack is actively animating an object */
132
+ export function getObjectAnimated(obj: Object3D): boolean {
133
+ if (!obj) return false;
134
+ const set = obj[$objectAnimationKey] as Set<object>;
135
+ return set !== undefined && set.size > 0;
136
+ }
137
+
138
+ // #region Autoplay
105
139
  /**
106
140
  * Assigns animations from a GLTF file to the objects in the scene.
107
141
  * This method will look for objects in the scene that have animations and assign them to the correct objects.
108
142
  * @param file The GLTF file to assign the animations from
109
143
  */
110
- static autoplayAnimations(file: Object3D | Pick<Model, "animations" | "scene">): Array<IAnimationComponent> | null {
144
+
145
+ export function autoplayAnimations(file: Object3D | Pick<Model, "animations" | "scene">): Array<IAnimationComponent> | null {
111
146
  if (!file || !file.animations) {
112
147
  console.debug("No animations found in file");
113
148
  return null;
@@ -171,11 +206,14 @@ export class AnimationUtils {
171
206
  }
172
207
 
173
208
 
174
- static emptyClip(): AnimationClip {
209
+ // #region Create Clips
210
+
211
+ export function emptyClip(): AnimationClip {
175
212
  return new AnimationClip("empty", 0, []);
176
213
  }
177
214
 
178
- static createScaleClip(options?: ScaleClipOptions): AnimationClip {
215
+
216
+ export function createScaleClip(options?: ScaleClipOptions): AnimationClip {
179
217
  const duration = options?.duration ?? 0.3;
180
218
 
181
219
  let baseScale: Vector3Like = { x: 1, y: 1, z: 1 };
@@ -1,4 +1,5 @@
1
1
  import { Object3D, Scene } from "three";
2
+ import { v5 } from 'uuid';
2
3
 
3
4
  import { ComponentLifecycleEvents } from "./engine_components_internal.js";
4
5
  import { activeInHierarchyFieldName } from "./engine_constants.js";
@@ -6,8 +7,35 @@ import { removeScriptFromContext, updateActiveInHierarchyWithoutEventCall } from
6
7
  import { InstantiateIdProvider } from "./engine_networking_instantiate.js";
7
8
  import { Context, registerComponent } from "./engine_setup.js";
8
9
  import type { ComponentInit, Constructor, ConstructorConcrete, IComponent, IGameObject } from "./engine_types.js";
10
+ import { $componentName } from "./engine_types.js";
9
11
  import { getParam } from "./engine_utils.js";
10
12
  import { apply } from "./js-extensions/index.js";
13
+ import { TypeStore } from "./engine_typestore.js";
14
+
15
+ const COMPONENT_GUID_NAMESPACE = 'eff8ba80-635d-11ec-90d6-0242ac120003';
16
+
17
+ /**
18
+ * Generates a deterministic guid for a component based on the object's guid and the component type.
19
+ * If the object has a guid, the component guid is derived from `objectGuid:TypeName:index`
20
+ * using UUID v5 hashing. This ensures the same component type added to the same object on
21
+ * different clients gets the same guid — critical for networked components like SyncedTransform.
22
+ * Falls back to the shared counter-based IdProvider if the object has no guid.
23
+ */
24
+ function generateDeterministicComponentGuid(obj: Object3D, component: IComponent): string {
25
+ const objectGuid = obj["guid"];
26
+ if (objectGuid) {
27
+ const typeName = component[$componentName] ?? component.constructor?.name ?? "Component";
28
+ // Count existing components of same type for uniqueness (e.g. multiple of the same component type)
29
+ const existingComponents = getComponents(obj, component.constructor as any);
30
+ let sameTypeCount = 0;
31
+ for (const c of existingComponents) {
32
+ if (c !== component) sameTypeCount++;
33
+ }
34
+ const key = `${objectGuid}:${typeName}:${sameTypeCount}`;
35
+ return v5(key, COMPONENT_GUID_NAMESPACE);
36
+ }
37
+ return getIdProvider().generateUUID();
38
+ }
11
39
 
12
40
 
13
41
  const debug = getParam("debuggetcomponent");
@@ -57,9 +85,8 @@ export function addNewComponent<T extends IComponent>(obj: Object3D, componentIn
57
85
  if (!obj.userData.components) obj.userData.components = [];
58
86
  obj.userData.components.push(componentInstance);
59
87
  componentInstance.gameObject = obj as IGameObject;
60
- // TODO: currently add component does not ensure a new component instance has a guid
61
88
  if (componentInstance.guid === undefined || componentInstance.guid === "invalid") {
62
- componentInstance.guid = getIdProvider().generateUUID();
89
+ componentInstance.guid = generateDeterministicComponentGuid(obj, componentInstance);
63
90
  }
64
91
  apply(obj);
65
92
  // register the component - make sure to provide the component instance context (if assigned)
@@ -109,9 +136,9 @@ export function addComponent<T extends IComponent>(obj: Object3D, componentInsta
109
136
  // componentInstance.__internalEnable();
110
137
  // componentInstance.transform = obj;
111
138
  if (componentInstance.guid === undefined || componentInstance.guid === "invalid") {
112
- componentInstance.guid = getIdProvider().generateUUID();
139
+ componentInstance.guid = generateDeterministicComponentGuid(obj, componentInstance);
113
140
  }
114
- if(init) componentInstance._internalInit(init);
141
+ if (init) componentInstance._internalInit(init);
115
142
  // Register the component - make sure to provide the component instance context (if assigned)
116
143
  registerComponent(componentInstance, componentInstance.context);
117
144
  return componentInstance;
@@ -144,10 +171,12 @@ function onGetComponent<T>(obj: Object3D | null | undefined, componentType: Cons
144
171
  }
145
172
  if (!(obj?.userData?.components)) return null;
146
173
  if (typeof componentType === "string") {
147
- if (!didWarnAboutComponentAccess) {
174
+ const type = TypeStore.get(componentType);
175
+ if (!didWarnAboutComponentAccess && !type) {
148
176
  didWarnAboutComponentAccess = true;
149
177
  console.warn(`Accessing components by name is not supported.\nPlease use the component type instead. This may keep working in local development but it will fail when bundling your application.\n\nYou can import other modules your main module to get access to types\nor if you use npmdefs you can make types available globally using globalThis:\nhttps://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis`, componentType);
150
178
  }
179
+ if (type) componentType = type as Constructor<T>;
151
180
  }
152
181
 
153
182
  if (debugEnabled())
@@ -222,7 +251,7 @@ export function getComponents<T extends IComponent>(obj: Object3D, componentType
222
251
  * ```
223
252
  */
224
253
  export function getComponentInChildren<T extends IComponent>(obj: Object3D, componentType: Constructor<T>, includeInactive: boolean = false): T | null {
225
- if (includeInactive === false && obj[activeInHierarchyFieldName] === false) return null;
254
+ if (includeInactive === false && obj[activeInHierarchyFieldName] === false) return null;
226
255
  const res = getComponent(obj, componentType) as IComponent | null;
227
256
  if (includeInactive === false && (res?.enabled === false || res?.activeAndEnabled === false)) return null;
228
257
  if (res) return res as T;
@@ -335,7 +364,7 @@ export function findObjectOfType<T extends IComponent>(type: Constructor<T>, con
335
364
  if (!scene) return null;
336
365
 
337
366
  const res = getComponentInChildren(scene, type, includeInactive);
338
- if(res) return res;
367
+ if (res) return res;
339
368
  return null;
340
369
  }
341
370
 
@@ -43,6 +43,7 @@ import { patchTonemapping } from './engine_tonemapping.js';
43
43
  import type { CoroutineData, ICamera, IComponent, IContext, ILight, LoadedModel, Model, SourceIdentifier, Vec2 } from "./engine_types.js";
44
44
  import { deepClone, delay, DeviceUtilities, getParam } from './engine_utils.js';
45
45
  import type { INeedleXRSessionEventReceiver, NeedleXRSession } from './engine_xr.js';
46
+ import { PostProcessing } from './postprocessing/index.js';
46
47
  import { NeedleMenu } from './webcomponents/needle menu/needle-menu.js';
47
48
  import type { NeedleEngineWebComponent } from './webcomponents/needle-engine.js';
48
49
 
@@ -387,9 +388,11 @@ export class Context implements IContext {
387
388
  */
388
389
  renderer!: WebGLRenderer;
389
390
  /**
390
- * The effect composer can be used to render postprocessing effects. If assigned then it will automatically render the scene every frame.
391
+ * The effect composer used for rendering postprocessing effects.
392
+ * @deprecated Use `context.postprocessing.composer` instead.
391
393
  */
392
- composer: EffectComposer | ThreeEffectComposer | null = null;
394
+ get composer(): EffectComposer | ThreeEffectComposer | null { return this.postprocessing.composer; }
395
+ set composer(value: EffectComposer | ThreeEffectComposer | null) { this.postprocessing.composer = value; }
393
396
 
394
397
  // #region internal script lists
395
398
  /**
@@ -504,6 +507,8 @@ export class Context implements IContext {
504
507
  input: Input;
505
508
  /** access physics related methods (e.g. raycasting). To access the phyiscs engine use `context.physics.engine` */
506
509
  physics: Physics;
510
+ /** access postprocessing effects stack. Add/remove effects and configure adaptive performance settings */
511
+ postprocessing: PostProcessing;
507
512
  /** access networking methods (use it to send or listen to messages or join a networking backend) */
508
513
  connection: NetworkConnection;
509
514
  /** @deprecated AssetDatabase is deprecated */
@@ -564,6 +569,7 @@ export class Context implements IContext {
564
569
  this.time = new Time();
565
570
  this.input = new Input(this);
566
571
  this.physics = new Physics(this);
572
+ this.postprocessing = new PostProcessing(this);
567
573
  this.connection = new NetworkConnection(this);
568
574
  // eslint-disable-next-line @typescript-eslint/no-deprecated
569
575
  this.assets = new AssetDatabase();
@@ -1778,6 +1784,9 @@ export class Context implements IContext {
1778
1784
  if (this.renderer.toneMapping !== NoToneMapping)
1779
1785
  patchTonemapping(this);
1780
1786
 
1787
+ // Update postprocessing stack (applies dirty effects, adaptive multisampling/pixel ratio)
1788
+ this.postprocessing.update();
1789
+
1781
1790
  if (this.composer && !this.isInXR) {
1782
1791
  // if a camera is passed in we need to check if we need to update the composer's camera
1783
1792
  if (camera && "setMainCamera" in this.composer) {