@vgai/editor 0.4.0 → 0.4.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 (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Registered project-tool discovery and execution.
3
+ *
4
+ * Project modules stay ordinary TS/JS. This server-side boundary is the only
5
+ * loader: it reads explicit `package.json#vgai.tools` registrations, asks the
6
+ * owning Vite server to load each callable module in Node, validates the
7
+ * exported ToolDefinition, and dispatches through the existing internal SDK
8
+ * registry. Optional React contributions are separate browser modules and are
9
+ * never imported merely because a callable tool was listed or executed.
10
+ */
11
+
12
+ import { readFile } from 'node:fs/promises';
13
+ import { createRequire } from 'node:module';
14
+ import { dirname, resolve, sep } from 'node:path';
15
+ import { z } from 'zod';
16
+ import type { OperationDefinition, OperationOutcome } from '../../vgai-sdk/src/registry';
17
+ import { OperationRegistry } from '../../vgai-sdk/src/registry';
18
+ import { runWithGenerativeExecutionCapture } from './generative-execution-context';
19
+ import { createProjectOutputWriter } from './project-output-writer';
20
+
21
+ export type ProjectModuleLoader = (absoluteModuleId: string) => Promise<unknown>;
22
+
23
+ export interface ProjectToolErrorSummary {
24
+ code: string;
25
+ summary: string;
26
+ dataSchema?: unknown;
27
+ }
28
+
29
+ export interface ProjectToolCatalogEntry {
30
+ name: string;
31
+ summary: string;
32
+ description: string;
33
+ sourcePath: string;
34
+ inputSchema: unknown;
35
+ resultSchema: unknown;
36
+ errors: ProjectToolErrorSummary[];
37
+ requires: Record<string, boolean | undefined>;
38
+ host: 'node' | 'editor-browser' | 'runtime-page';
39
+ mutates: boolean;
40
+ supportsDryRun: boolean;
41
+ longRunning: boolean;
42
+ permission: { risk: 'read' | 'write' | 'destructive'; summary: string };
43
+ contributions: ProjectToolContribution[];
44
+ }
45
+
46
+ export type ToolContributionPoint =
47
+ | 'workspace.document'
48
+ | 'selection.inspector'
49
+ | 'workspace.utility';
50
+
51
+ export interface ProjectToolContribution {
52
+ id: string;
53
+ point: ToolContributionPoint;
54
+ title: string;
55
+ entryPath: string;
56
+ }
57
+
58
+ export interface ProjectToolLoadError {
59
+ sourcePath: string;
60
+ message: string;
61
+ }
62
+
63
+ export interface ProjectToolCatalog {
64
+ tools: ProjectToolCatalogEntry[];
65
+ loadErrors: ProjectToolLoadError[];
66
+ }
67
+
68
+ interface LoadedCatalog extends ProjectToolCatalog {
69
+ definitions: Map<string, OperationDefinition>;
70
+ }
71
+
72
+ interface ToolModule {
73
+ absolutePath: string;
74
+ sourcePath: string;
75
+ contributions: ProjectToolContribution[];
76
+ registrationError?: string;
77
+ }
78
+
79
+ interface ToolContributionRegistration {
80
+ id: string;
81
+ point: ToolContributionPoint;
82
+ title: string;
83
+ entry: string;
84
+ }
85
+
86
+ type ToolRegistration =
87
+ | string
88
+ | {
89
+ entry: string;
90
+ contributions?: ToolContributionRegistration[];
91
+ };
92
+
93
+ interface PackageJsonShape {
94
+ name?: string;
95
+ dependencies?: Record<string, string>;
96
+ devDependencies?: Record<string, string>;
97
+ vgai?: { tools?: ToolRegistration[] };
98
+ }
99
+
100
+ async function readPackageJson(path: string): Promise<PackageJsonShape | null> {
101
+ try {
102
+ return JSON.parse(await readFile(path, 'utf8')) as PackageJsonShape;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function resolveDependencyPackageJson(
109
+ requireFromProject: ReturnType<typeof createRequire>,
110
+ dependencyName: string,
111
+ ): string | null {
112
+ try {
113
+ return requireFromProject.resolve(`${dependencyName}/package.json`);
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ function registrationEntry(registration: ToolRegistration): string | null {
120
+ return typeof registration === 'string' ? registration : registration?.entry;
121
+ }
122
+
123
+ function registrationContributions(
124
+ registration: ToolRegistration,
125
+ ): ToolContributionRegistration[] | undefined {
126
+ return typeof registration === 'object' && registration !== null
127
+ ? registration.contributions
128
+ : undefined;
129
+ }
130
+
131
+ function validRelativeModule(spec: unknown): spec is string {
132
+ return typeof spec === 'string' && spec.startsWith('./') && !spec.includes('..');
133
+ }
134
+
135
+ function validContributionPoint(value: unknown): value is ToolContributionPoint {
136
+ return (
137
+ value === 'workspace.document' ||
138
+ value === 'selection.inspector' ||
139
+ value === 'workspace.utility'
140
+ );
141
+ }
142
+
143
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one explicit manifest validator keeps every contribution field and teaching error at the package boundary.
144
+ function resolveContributions(
145
+ registrations: unknown,
146
+ root: string,
147
+ dependency: boolean,
148
+ ): { contributions: ProjectToolContribution[]; error?: string } {
149
+ if (registrations === undefined) return { contributions: [] };
150
+ if (!Array.isArray(registrations)) {
151
+ return { contributions: [], error: '`contributions` must be an array' };
152
+ }
153
+ const contributions: ProjectToolContribution[] = [];
154
+ const ids = new Set<string>();
155
+ for (const [index, value] of registrations.entries()) {
156
+ if (!value || typeof value !== 'object') {
157
+ return { contributions: [], error: `contributions[${index}] must be an object` };
158
+ }
159
+ const candidate = value as Partial<ToolContributionRegistration>;
160
+ if (typeof candidate.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(candidate.id)) {
161
+ return {
162
+ contributions: [],
163
+ error: `contributions[${index}].id must be a lowercase kebab-case id`,
164
+ };
165
+ }
166
+ if (ids.has(candidate.id)) {
167
+ return {
168
+ contributions: [],
169
+ error: `duplicate contribution id ${JSON.stringify(candidate.id)}`,
170
+ };
171
+ }
172
+ if (!validContributionPoint(candidate.point)) {
173
+ return {
174
+ contributions: [],
175
+ error:
176
+ `contributions[${index}].point must be ` +
177
+ `'workspace.document', 'selection.inspector', or 'workspace.utility'`,
178
+ };
179
+ }
180
+ if (typeof candidate.title !== 'string' || candidate.title.trim() === '') {
181
+ return { contributions: [], error: `contributions[${index}].title must be non-empty` };
182
+ }
183
+ if (!validRelativeModule(candidate.entry)) {
184
+ return {
185
+ contributions: [],
186
+ error: `contributions[${index}].entry must be a ./-relative module without parent traversal`,
187
+ };
188
+ }
189
+ const absoluteEntry = resolve(root, candidate.entry);
190
+ if (!absoluteEntry.startsWith(`${root}${sep}`)) {
191
+ return {
192
+ contributions: [],
193
+ error: `contributions[${index}].entry resolves outside its package`,
194
+ };
195
+ }
196
+ ids.add(candidate.id);
197
+ contributions.push({
198
+ id: candidate.id,
199
+ point: candidate.point,
200
+ title: candidate.title.trim(),
201
+ entryPath: dependency ? absoluteEntry : candidate.entry.slice(2),
202
+ });
203
+ }
204
+ return { contributions };
205
+ }
206
+
207
+ async function advertisedDependencyTools(
208
+ requireFromProject: ReturnType<typeof createRequire>,
209
+ dependencyName: string,
210
+ ): Promise<ToolModule[]> {
211
+ const packageJsonPath = resolveDependencyPackageJson(requireFromProject, dependencyName);
212
+ if (!packageJsonPath) return [];
213
+ const dependencyPackage = await readPackageJson(packageJsonPath);
214
+ const registrations = dependencyPackage?.vgai?.tools;
215
+ if (!Array.isArray(registrations)) return [];
216
+ const packageRoot = dirname(packageJsonPath);
217
+ return registrations.map((registration, index): ToolModule => {
218
+ const entry = registrationEntry(registration);
219
+ const registrationPath = `${dependencyName}:package.json#vgai.tools[${index}]`;
220
+ if (!validRelativeModule(entry)) {
221
+ return {
222
+ absolutePath: packageJsonPath,
223
+ sourcePath: registrationPath,
224
+ contributions: [],
225
+ registrationError: 'must be a ./-relative tool module without parent traversal',
226
+ };
227
+ }
228
+ const absolutePath = resolve(packageRoot, entry);
229
+ if (!absolutePath.startsWith(`${packageRoot}${sep}`)) {
230
+ return {
231
+ absolutePath,
232
+ sourcePath: registrationPath,
233
+ contributions: [],
234
+ registrationError: 'resolves outside the dependency package',
235
+ };
236
+ }
237
+ const resolved = resolveContributions(
238
+ registrationContributions(registration),
239
+ packageRoot,
240
+ true,
241
+ );
242
+ if (resolved.error) {
243
+ return {
244
+ absolutePath,
245
+ sourcePath: registrationPath,
246
+ contributions: [],
247
+ registrationError: resolved.error,
248
+ };
249
+ }
250
+ return {
251
+ absolutePath,
252
+ sourcePath: `${dependencyName}:${entry.slice(2)}`,
253
+ contributions: resolved.contributions,
254
+ };
255
+ });
256
+ }
257
+
258
+ async function dependencyToolModules(projectRoot: string): Promise<ToolModule[]> {
259
+ const projectPackage = await readPackageJson(resolve(projectRoot, 'package.json'));
260
+ if (!projectPackage) return [];
261
+ const dependencyNames = [
262
+ ...Object.keys(projectPackage.dependencies ?? {}),
263
+ ...Object.keys(projectPackage.devDependencies ?? {}),
264
+ ].sort();
265
+ const requireFromProject = createRequire(resolve(projectRoot, 'package.json'));
266
+ return (
267
+ await Promise.all(
268
+ dependencyNames.map((name) => advertisedDependencyTools(requireFromProject, name)),
269
+ )
270
+ ).flat();
271
+ }
272
+
273
+ async function projectToolModules(projectRoot: string): Promise<ToolModule[]> {
274
+ const projectPackage = await readPackageJson(resolve(projectRoot, 'package.json'));
275
+ const registrations = projectPackage?.vgai?.tools;
276
+ if (!Array.isArray(registrations)) return [];
277
+ return registrations.map((registration, index): ToolModule => {
278
+ const entry = registrationEntry(registration);
279
+ const registrationPath = `package.json#vgai.tools[${index}]`;
280
+ if (!validRelativeModule(entry)) {
281
+ return {
282
+ absolutePath: resolve(projectRoot, 'package.json'),
283
+ sourcePath: registrationPath,
284
+ contributions: [],
285
+ registrationError: 'must be a ./-relative tool module without parent traversal',
286
+ };
287
+ }
288
+ const absolutePath = resolve(projectRoot, entry);
289
+ if (!absolutePath.startsWith(`${projectRoot}${sep}`)) {
290
+ return {
291
+ absolutePath,
292
+ sourcePath: registrationPath,
293
+ contributions: [],
294
+ registrationError: 'resolves outside the project',
295
+ };
296
+ }
297
+ const resolved = resolveContributions(
298
+ registrationContributions(registration),
299
+ projectRoot,
300
+ false,
301
+ );
302
+ if (resolved.error) {
303
+ return {
304
+ absolutePath,
305
+ sourcePath: registrationPath,
306
+ contributions: [],
307
+ registrationError: resolved.error,
308
+ };
309
+ }
310
+ return {
311
+ absolutePath,
312
+ sourcePath: entry.slice(2),
313
+ contributions: resolved.contributions,
314
+ };
315
+ });
316
+ }
317
+
318
+ function operationShape(value: unknown): value is OperationDefinition {
319
+ if (value === null || typeof value !== 'object') return false;
320
+ const candidate = value as Partial<OperationDefinition>;
321
+ return (
322
+ typeof candidate.name === 'string' &&
323
+ typeof candidate.summary === 'string' &&
324
+ typeof candidate.description === 'string' &&
325
+ candidate.input !== null &&
326
+ typeof candidate.input === 'object' &&
327
+ typeof candidate.input.safeParse === 'function' &&
328
+ candidate.result !== null &&
329
+ typeof candidate.result === 'object' &&
330
+ typeof candidate.result.safeParse === 'function' &&
331
+ Array.isArray(candidate.errors) &&
332
+ candidate.requires !== null &&
333
+ typeof candidate.requires === 'object' &&
334
+ (candidate.host === 'node' ||
335
+ candidate.host === 'editor-browser' ||
336
+ candidate.host === 'runtime-page') &&
337
+ typeof candidate.mutates === 'boolean' &&
338
+ typeof candidate.supportsDryRun === 'boolean' &&
339
+ candidate.permission !== null &&
340
+ typeof candidate.permission === 'object' &&
341
+ typeof candidate.impl === 'function'
342
+ );
343
+ }
344
+
345
+ function jsonSchema(schema: z.ZodType): unknown {
346
+ return z.toJSONSchema(schema);
347
+ }
348
+
349
+ function toCatalogEntry(
350
+ definition: OperationDefinition,
351
+ sourcePath: string,
352
+ contributions: ProjectToolContribution[],
353
+ ): ProjectToolCatalogEntry {
354
+ return {
355
+ name: definition.name,
356
+ summary: definition.summary,
357
+ description: definition.description,
358
+ sourcePath,
359
+ inputSchema: jsonSchema(definition.input),
360
+ resultSchema: jsonSchema(definition.result),
361
+ errors: definition.errors.map((error) => ({
362
+ code: error.code,
363
+ summary: error.summary,
364
+ ...(error.data ? { dataSchema: jsonSchema(error.data) } : {}),
365
+ })),
366
+ requires: { ...definition.requires },
367
+ host: definition.host,
368
+ mutates: definition.mutates,
369
+ supportsDryRun: definition.supportsDryRun,
370
+ longRunning: definition.longRunning === true,
371
+ permission: { ...definition.permission },
372
+ contributions,
373
+ };
374
+ }
375
+
376
+ async function loadCatalog(
377
+ projectRoot: string,
378
+ loadModule: ProjectModuleLoader | undefined,
379
+ ): Promise<LoadedCatalog> {
380
+ const definitions = new Map<string, OperationDefinition>();
381
+ const tools: ProjectToolCatalogEntry[] = [];
382
+ const loadErrors: ProjectToolLoadError[] = [];
383
+ const files = [
384
+ ...(await projectToolModules(projectRoot)),
385
+ ...(await dependencyToolModules(projectRoot)),
386
+ ].sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
387
+
388
+ const loadableFiles = files.filter((file) => {
389
+ if (!file.registrationError) return true;
390
+ loadErrors.push({ sourcePath: file.sourcePath, message: file.registrationError });
391
+ return false;
392
+ });
393
+
394
+ if (loadableFiles.length > 0 && !loadModule) {
395
+ loadErrors.push({
396
+ sourcePath: 'package.json#vgai.tools',
397
+ message:
398
+ 'This editor host cannot load project TypeScript modules. Use the Vite-backed dev or packaged editor.',
399
+ });
400
+ return { tools, loadErrors, definitions };
401
+ }
402
+
403
+ const registry = new OperationRegistry();
404
+ for (const { absolutePath, sourcePath, contributions } of loadableFiles) {
405
+ try {
406
+ const imported = (await loadModule?.(absolutePath)) as Record<string, unknown> | undefined;
407
+ const definition = imported?.['tool'];
408
+ if (!operationShape(definition)) {
409
+ throw new Error(
410
+ 'does not export a complete `tool` definition; export `tool = defineTool({...})` from @vgai/sdk/tools',
411
+ );
412
+ }
413
+ if (!definition.name.startsWith('project.')) {
414
+ throw new Error(
415
+ `declares ${JSON.stringify(definition.name)}; project-local tool names must start with "project."`,
416
+ );
417
+ }
418
+ if (definition.mutates && definition.permission.risk === 'read') {
419
+ throw new Error(
420
+ `declares mutates:true with read risk; mutating operations must declare write or destructive risk`,
421
+ );
422
+ }
423
+ registry.register(definition);
424
+ const entry = toCatalogEntry(definition, sourcePath, contributions);
425
+ definitions.set(definition.name, definition);
426
+ tools.push(entry);
427
+ } catch (error) {
428
+ loadErrors.push({
429
+ sourcePath,
430
+ message: error instanceof Error ? error.message : String(error),
431
+ });
432
+ }
433
+ }
434
+
435
+ tools.sort((a, b) => a.name.localeCompare(b.name));
436
+ return { tools, loadErrors, definitions };
437
+ }
438
+
439
+ export async function discoverProjectTools(
440
+ projectRoot: string,
441
+ loadModule: ProjectModuleLoader | undefined,
442
+ ): Promise<ProjectToolCatalog> {
443
+ const { definitions: _definitions, ...catalog } = await loadCatalog(projectRoot, loadModule);
444
+ return catalog;
445
+ }
446
+
447
+ export type ProjectToolExecution =
448
+ | { status: 200; body: OperationOutcome; changedProject: boolean }
449
+ | {
450
+ status: 404 | 409;
451
+ body: { ok: false; error: { code: string; message: string } };
452
+ changedProject: false;
453
+ };
454
+
455
+ export async function executeProjectTool(options: {
456
+ projectRoot: string;
457
+ loadModule: ProjectModuleLoader | undefined;
458
+ name: string;
459
+ input: unknown;
460
+ confirmed: boolean;
461
+ signal?: AbortSignal;
462
+ }): Promise<ProjectToolExecution> {
463
+ const loaded = await loadCatalog(options.projectRoot, options.loadModule);
464
+ const definition = loaded.definitions.get(options.name);
465
+ if (!definition) {
466
+ return {
467
+ status: 404,
468
+ changedProject: false,
469
+ body: {
470
+ ok: false,
471
+ error: {
472
+ code: 'PROJECT_TOOL_NOT_FOUND',
473
+ message: `No registered project tool is named ${JSON.stringify(options.name)}.`,
474
+ },
475
+ },
476
+ };
477
+ }
478
+ if (definition.host !== 'node') {
479
+ return {
480
+ status: 409,
481
+ changedProject: false,
482
+ body: {
483
+ ok: false,
484
+ error: {
485
+ code: 'PROJECT_TOOL_HOST_UNAVAILABLE',
486
+ message:
487
+ `Project tool ${JSON.stringify(options.name)} declares host ${JSON.stringify(definition.host)}. ` +
488
+ 'This host currently executes Node-hosted project tools; call reusable browser/runtime functions directly.',
489
+ },
490
+ },
491
+ };
492
+ }
493
+ if ((definition.mutates || definition.permission.risk !== 'read') && !options.confirmed) {
494
+ return {
495
+ status: 409,
496
+ changedProject: false,
497
+ body: {
498
+ ok: false,
499
+ error: {
500
+ code: 'PROJECT_TOOL_CONFIRMATION_REQUIRED',
501
+ message:
502
+ `${definition.permission.risk} tool ${JSON.stringify(options.name)} requires explicit confirmation: ` +
503
+ definition.permission.summary,
504
+ },
505
+ },
506
+ };
507
+ }
508
+
509
+ const registry = new OperationRegistry();
510
+ registry.register(definition);
511
+ const provenanceInput = definition.input.safeParse(options.input);
512
+ const operationSource = loaded.tools.find((entry) => entry.name === options.name)?.sourcePath;
513
+ const outcome = await runWithGenerativeExecutionCapture(() =>
514
+ registry.dispatch(options.name, options.input, {
515
+ projectRoot: options.projectRoot,
516
+ projectOutputs: createProjectOutputWriter(options.projectRoot, {
517
+ operationName: definition.name,
518
+ ...(operationSource ? { operationSource } : {}),
519
+ ...(provenanceInput.success ? { input: provenanceInput.data } : {}),
520
+ }),
521
+ ...(options.signal ? { signal: options.signal } : {}),
522
+ }),
523
+ );
524
+ return {
525
+ status: 200,
526
+ body: outcome,
527
+ changedProject: definition.mutates && outcome.ok,
528
+ };
529
+ }
@@ -336,7 +336,7 @@ export function validateManifestWrite(
336
336
  * Which editor list a `src/**` add/unlink invalidates, or `null` if it's
337
337
  * irrelevant. Feeds the editor-server's second `src/`-scoped chokidar
338
338
  * watcher (`startWatcher()` in editor-server.ts) so a NEW
339
- * `src/tools/*.tool.tsx`, project operations, or data/schema files show up
339
+ * registered tool modules or data/schema files show up
340
340
  * in the dock/DATA tab without a full editor reload (spec §7 W4 field note
341
341
  * c). `.schema.json` is included on the `data` side: its add/unlink flips the
342
342
  * DATA tab between the no-emitted-schema teaching message and real widgets.
@@ -344,18 +344,21 @@ export function validateManifestWrite(
344
344
  * `*.stories.tsx`/`*.stories.ts` anywhere under `src/` — the Stories tab.
345
345
  *
346
346
  * `relPath` is project-root-relative with forward slashes (same convention
347
- * `/__editor/data-files` and `/__editor/tool-files` already use).
347
+ * `/__editor/data-files` already uses).
348
348
  */
349
- export function classifyProjectSrcPath(
350
- relPath: string,
351
- ): 'tools' | 'data' | 'stories' | 'operations' | null {
352
- if (relPath.startsWith('src/tools/') && relPath.endsWith('.tool.tsx')) return 'tools';
349
+ export function classifyProjectSrcPath(relPath: string): 'tools' | 'data' | 'stories' | null {
353
350
  if (
354
- relPath.startsWith('src/operations/') &&
355
- (relPath.endsWith('.operation.ts') || relPath.endsWith('.operation.js'))
356
- ) {
357
- return 'operations';
358
- }
351
+ relPath.startsWith('src/tools/') &&
352
+ (relPath.endsWith('.tool.ts') ||
353
+ relPath.endsWith('.tool.js') ||
354
+ relPath.endsWith('.document.tsx') ||
355
+ relPath.endsWith('.document.jsx') ||
356
+ relPath.endsWith('.inspector.tsx') ||
357
+ relPath.endsWith('.inspector.jsx') ||
358
+ relPath.endsWith('.utility.tsx') ||
359
+ relPath.endsWith('.utility.jsx'))
360
+ )
361
+ return 'tools';
359
362
  if (
360
363
  relPath.startsWith('src/data/') &&
361
364
  (relPath.endsWith('.data.json') || relPath.endsWith('.schema.json'))
@@ -18,9 +18,9 @@ import { createHmrStableReactContext } from './hmr-stable-react-context';
18
18
  import { initProject } from './initial-scene';
19
19
  import { type InteractiveEditScope, InteractiveEditScopeContext } from './interactive-edit-scope';
20
20
  import { getCurrentProject } from './project-manager';
21
- import { startProjectOperationDiscovery } from './project-operations';
22
21
  import { startProjectStoryDiscovery } from './project-story-discovery';
23
- import { startProjectToolDiscovery } from './project-tool-discovery';
22
+ import { startProjectToolContributionDiscovery } from './project-tool-discovery';
23
+ import { startProjectToolCatalog } from './project-tools';
24
24
 
25
25
  export interface EditorStats {
26
26
  fps: number;
@@ -92,9 +92,9 @@ export function EditorProvider({ children }: { children: ReactNode }) {
92
92
  // keeps re-running on project change / tool-file add/unlink) whether or
93
93
  // not the Dockview workspace is mounted. WorkspaceDock only consumes the
94
94
  // discovered registrations.
95
- useEffect(() => startProjectToolDiscovery(), []);
95
+ useEffect(() => startProjectToolContributionDiscovery(), []);
96
96
 
97
- useEffect(() => startProjectOperationDiscovery(), []);
97
+ useEffect(() => startProjectToolCatalog(), []);
98
98
 
99
99
  // CSF stories may exist without a currently mounted React root. Discover
100
100
  // them at editor init so hierarchy/Inspector story affordances and restored
@@ -203,6 +203,18 @@ export function buildStaticActions(
203
203
  category: 'action',
204
204
  execute: () => store.setShadingMode('unlit'),
205
205
  },
206
+ {
207
+ id: 'shading.normals',
208
+ label: 'Normal Shading',
209
+ category: 'action',
210
+ execute: () => store.setShadingMode('normals'),
211
+ },
212
+ {
213
+ id: 'shading.overdraw',
214
+ label: 'Overdraw Shading',
215
+ category: 'action',
216
+ execute: () => store.setShadingMode('overdraw'),
217
+ },
206
218
  {
207
219
  id: 'view.top',
208
220
  label: 'Top View',
@@ -10,6 +10,7 @@ import type { ReactNode } from 'react';
10
10
  import { useSyncExternalStore } from 'react';
11
11
  import { themeVars } from './design-system';
12
12
  import { createHmrRegistrationGroup } from './hmr-registration-group';
13
+ import { closeWorkspaceUtility } from './workspace-dock-controller';
13
14
  import { registerWorkspaceStatus } from './workspace-status-registry';
14
15
  import { registerWorkspaceUtility } from './workspace-utility-registry';
15
16
 
@@ -62,6 +63,14 @@ export function activateAssetEditorContext(value: AssetEditorContextValue): () =
62
63
  if (active?.token !== token) return;
63
64
  active = null;
64
65
  notifyChanged();
66
+ // React cleans the outgoing document effect before activating the next
67
+ // document's effect. Defer one microtask so model → model keeps the same
68
+ // Controls tab, while model → scene/image closes the now-ownerless tab.
69
+ queueMicrotask(() => {
70
+ if (getActiveAssetEditorContext()?.controls == null) {
71
+ closeWorkspaceUtility(ASSET_EDITOR_CONTROLS_UTILITY_ID);
72
+ }
73
+ });
65
74
  };
66
75
  }
67
76
 
@@ -110,11 +110,9 @@ export function connectSceneEvents(onSceneChanged: (event: SceneChangedEvent) =>
110
110
  * Connect to the editor server's SSE endpoint for project TOOL file
111
111
  * add/unlink events (W6a, docs/DATA-TOOLS-DESIGN.md §7 W4 field note c). The
112
112
  * server's `src/`-scoped watcher (`editor-server.ts`'s `startWatcher()`)
113
- * broadcasts `tool-files-changed` whenever a `src/tools/*.tool.tsx` is added
114
- * or removed; the event carries no payload of substance beyond the touched
115
- * path callers just re-run their own full rescan (`refreshProjectTools` is
116
- * cheap by design, same physics as the D3 "discovery IS the folder scan"
117
- * rule the DATA tab already follows).
113
+ * broadcasts `tool-files-changed` whenever a registered tool or contribution
114
+ * source is added, changed, or removed. The event carries no payload of
115
+ * substance beyond the touched path; callers refresh package registrations.
118
116
  */
119
117
  export function connectToolFileEvents(onChanged: () => void): () => void {
120
118
  // Browser mode: no Node dev server / SSE — a project-src file add/remove
@@ -125,16 +123,6 @@ export function connectToolFileEvents(onChanged: () => void): () => void {
125
123
  return () => source.close();
126
124
  }
127
125
 
128
- /** Project-operation add/change/unlink events. Operation definitions are
129
- * Node-loaded, so all three changes trigger a fresh catalog read rather than
130
- * relying on the browser-only project-tool HMR path. */
131
- export function connectOperationFileEvents(onChanged: () => void): () => void {
132
- if (getEditorMode() === 'browser') return () => {};
133
- const source = connectEvents();
134
- source.addEventListener('operation-files-changed', () => onChanged());
135
- return () => source.close();
136
- }
137
-
138
126
  /**
139
127
  * Connect to the editor server's SSE endpoint for project DATA file
140
128
  * add/unlink events (W6a). Mirrors `connectToolFileEvents` for
@@ -36,6 +36,9 @@ export interface SelectedAsset {
36
36
  readonly health?: 'healthy' | 'source-only' | 'unsupported' | 'stale-import';
37
37
  readonly sourcePath?: string;
38
38
  readonly selectionCount?: number;
39
+ readonly selectionTotalBytes?: number;
40
+ readonly selectionFormats?: readonly string[];
41
+ readonly selectionReimportableCount?: number;
39
42
  readonly dependencies?: readonly string[];
40
43
  readonly references?: readonly { ownerPath: string; jsonPath: string }[];
41
44
  readonly healthCodes?: readonly string[];