@dreamerbird/camera-studio 0.2.0 → 0.3.0

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 (126) hide show
  1. package/dist/CameraScene.d.ts +1 -1
  2. package/dist/CameraScene.d.ts.map +1 -1
  3. package/dist/CameraScene.js +36 -6
  4. package/dist/PlacementManager.d.ts +15 -1
  5. package/dist/PlacementManager.d.ts.map +1 -1
  6. package/dist/PlacementManager.js +123 -14
  7. package/dist/agent/api.d.ts +5 -0
  8. package/dist/agent/api.d.ts.map +1 -1
  9. package/dist/agent/api.js +25 -0
  10. package/dist/agent/api.test.js +75 -1
  11. package/dist/agent/commands/assets.d.ts +5 -0
  12. package/dist/agent/commands/assets.d.ts.map +1 -1
  13. package/dist/agent/commands/assets.js +93 -21
  14. package/dist/agent/commands/assets.test.js +53 -0
  15. package/dist/agent/commands/materials.d.ts +18 -0
  16. package/dist/agent/commands/materials.d.ts.map +1 -0
  17. package/dist/agent/commands/materials.js +192 -0
  18. package/dist/agent/commands/materials.test.d.ts +2 -0
  19. package/dist/agent/commands/materials.test.d.ts.map +1 -0
  20. package/dist/agent/commands/materials.test.js +294 -0
  21. package/dist/agent/commands/modelRecovery.test.d.ts +2 -0
  22. package/dist/agent/commands/modelRecovery.test.d.ts.map +1 -0
  23. package/dist/agent/commands/modelRecovery.test.js +122 -0
  24. package/dist/agent/commands/models.d.ts +13 -0
  25. package/dist/agent/commands/models.d.ts.map +1 -1
  26. package/dist/agent/commands/models.js +98 -1
  27. package/dist/agent/commands/primitives.d.ts +27 -0
  28. package/dist/agent/commands/primitives.d.ts.map +1 -0
  29. package/dist/agent/commands/primitives.js +117 -0
  30. package/dist/agent/commands/primitives.test.d.ts +2 -0
  31. package/dist/agent/commands/primitives.test.d.ts.map +1 -0
  32. package/dist/agent/commands/primitives.test.js +135 -0
  33. package/dist/agent/expectedCommands.d.ts +1 -1
  34. package/dist/agent/expectedCommands.d.ts.map +1 -1
  35. package/dist/agent/expectedCommands.js +6 -0
  36. package/dist/agent/propAccess.d.ts +2 -0
  37. package/dist/agent/propAccess.d.ts.map +1 -1
  38. package/dist/agent/propAccess.js +21 -0
  39. package/dist/agent/registerCommands.d.ts.map +1 -1
  40. package/dist/agent/registerCommands.js +5 -1
  41. package/dist/agent/runtime/objectRuntime.d.ts +2 -0
  42. package/dist/agent/runtime/objectRuntime.d.ts.map +1 -1
  43. package/dist/agent/runtime/objectRuntime.js +15 -1
  44. package/dist/agent/runtime.d.ts +1 -0
  45. package/dist/agent/runtime.d.ts.map +1 -1
  46. package/dist/agent/runtime.js +2 -0
  47. package/dist/assetLibrary/dispose.d.ts +4 -2
  48. package/dist/assetLibrary/dispose.d.ts.map +1 -1
  49. package/dist/assetLibrary/dispose.js +7 -5
  50. package/dist/assetLibrary/gltfLoader.d.ts +2 -1
  51. package/dist/assetLibrary/gltfLoader.d.ts.map +1 -1
  52. package/dist/assetLibrary/gltfLoader.js +2 -2
  53. package/dist/assetLibrary/gltfResourceSet.d.ts +6 -0
  54. package/dist/assetLibrary/gltfResourceSet.d.ts.map +1 -0
  55. package/dist/assetLibrary/gltfResourceSet.js +27 -0
  56. package/dist/assetLibrary/gltfResourceSet.test.d.ts +2 -0
  57. package/dist/assetLibrary/gltfResourceSet.test.d.ts.map +1 -0
  58. package/dist/assetLibrary/gltfResourceSet.test.js +40 -0
  59. package/dist/assetLibrary/importModelFile.d.ts +5 -0
  60. package/dist/assetLibrary/importModelFile.d.ts.map +1 -1
  61. package/dist/assetLibrary/importModelFile.js +3 -16
  62. package/dist/assetLibrary/importModelFiles.d.ts +15 -0
  63. package/dist/assetLibrary/importModelFiles.d.ts.map +1 -0
  64. package/dist/assetLibrary/importModelFiles.js +58 -0
  65. package/dist/assetLibrary/importModelFiles.test.d.ts +2 -0
  66. package/dist/assetLibrary/importModelFiles.test.d.ts.map +1 -0
  67. package/dist/assetLibrary/importModelFiles.test.js +217 -0
  68. package/dist/assetLibrary/importPipeline.d.ts +15 -1
  69. package/dist/assetLibrary/importPipeline.d.ts.map +1 -1
  70. package/dist/assetLibrary/importPipeline.js +74 -38
  71. package/dist/assetLibrary/importPipeline.test.js +25 -0
  72. package/dist/assetLibrary/library.d.ts +1 -0
  73. package/dist/assetLibrary/library.d.ts.map +1 -1
  74. package/dist/assetLibrary/library.js +22 -1
  75. package/dist/assetLibrary/library.test.js +76 -0
  76. package/dist/bootstrap.d.ts +2 -1
  77. package/dist/bootstrap.d.ts.map +1 -1
  78. package/dist/bootstrap.js +69 -7
  79. package/dist/bootstrap.test.js +111 -0
  80. package/dist/config.d.ts +10 -1
  81. package/dist/config.d.ts.map +1 -1
  82. package/dist/hostFlows.d.ts +1 -0
  83. package/dist/hostFlows.d.ts.map +1 -1
  84. package/dist/hostFlows.js +9 -0
  85. package/dist/index.d.ts +10 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +6 -1
  88. package/dist/materials/modelMaterials.d.ts +19 -0
  89. package/dist/materials/modelMaterials.d.ts.map +1 -0
  90. package/dist/materials/modelMaterials.js +253 -0
  91. package/dist/materials/modelMaterials.test.d.ts +2 -0
  92. package/dist/materials/modelMaterials.test.d.ts.map +1 -0
  93. package/dist/materials/modelMaterials.test.js +188 -0
  94. package/dist/materials/types.d.ts +20 -0
  95. package/dist/materials/types.d.ts.map +1 -0
  96. package/dist/materials/types.js +1 -0
  97. package/dist/materials/useModelMaterials.d.ts +10 -0
  98. package/dist/materials/useModelMaterials.d.ts.map +1 -0
  99. package/dist/materials/useModelMaterials.js +26 -0
  100. package/dist/materials/useModelMaterials.test.d.ts +2 -0
  101. package/dist/materials/useModelMaterials.test.d.ts.map +1 -0
  102. package/dist/materials/useModelMaterials.test.js +75 -0
  103. package/dist/poses.d.ts +38 -0
  104. package/dist/poses.d.ts.map +1 -0
  105. package/dist/poses.js +41 -0
  106. package/dist/poses.test.d.ts +2 -0
  107. package/dist/poses.test.d.ts.map +1 -0
  108. package/dist/poses.test.js +38 -0
  109. package/dist/primitives.d.ts +5 -0
  110. package/dist/primitives.d.ts.map +1 -0
  111. package/dist/primitives.js +15 -0
  112. package/dist/primitives.test.d.ts +2 -0
  113. package/dist/primitives.test.d.ts.map +1 -0
  114. package/dist/primitives.test.js +27 -0
  115. package/dist/reset.realStudio.test.d.ts +2 -0
  116. package/dist/reset.realStudio.test.d.ts.map +1 -0
  117. package/dist/reset.realStudio.test.js +60 -0
  118. package/dist/studioRoot.d.ts +4 -0
  119. package/dist/studioRoot.d.ts.map +1 -0
  120. package/dist/studioRoot.js +36 -0
  121. package/dist/studioRoot.test.d.ts +2 -0
  122. package/dist/studioRoot.test.d.ts.map +1 -0
  123. package/dist/studioRoot.test.js +62 -0
  124. package/dist/useCameraStudio.d.ts.map +1 -1
  125. package/dist/useCameraStudio.js +3 -2
  126. package/package.json +9 -1
@@ -5,7 +5,7 @@
5
5
  * 公开 JS API 门面与启动生命周期(2026-10-19,Task 15,spec §7):
6
6
  *
7
7
  * suite 1 命名空间转发:fake runtime + stub registry,遍历 spec §9 全部
8
- * 71 条命令对应的门面方法,断言转发到同名命令、参数原样透传(同引用)、
8
+ * 75 条命令对应的门面方法,断言转发到同名命令、参数原样透传(同引用)、
9
9
  * 返回原始领域结果(绝不包装 {ok,result}),错误以 AgentApiError 原样抛出;
10
10
  * 另用真实 registerAllAgentCommands 断言注册清单与 spec 命令面一致、
11
11
  * 门面映射与注册表零漂移。
@@ -163,6 +163,7 @@ jest.doMock('../PlacementManager', () => ({
163
163
  startPlacingFromLibrary: async () => { },
164
164
  // 与生产形状一致:Agent createModel handler 的 {key, persisted} 契约
165
165
  placeLibraryEntryAt: () => ({ key: 'm1', persisted: Promise.resolve() }),
166
+ placePrimitiveAt: () => ({ key: 'box', persisted: Promise.resolve() }),
166
167
  cancelPlacing: noop,
167
168
  confirmPlacing: noop,
168
169
  clearPending: noop,
@@ -274,6 +275,35 @@ const FACADE_COMMANDS = [
274
275
  command: 'models.create',
275
276
  args: { assetId: 'asset-1', position: { x: 0, y: 0, z: 0 } },
276
277
  },
278
+ {
279
+ path: 'models.retry',
280
+ command: 'models.retry',
281
+ args: { objectId: 'model:M' },
282
+ },
283
+ {
284
+ path: 'models.replace',
285
+ command: 'models.replace',
286
+ args: { objectId: 'model:M', assetId: 'asset-2' },
287
+ },
288
+ {
289
+ path: 'primitives.create',
290
+ command: 'primitives.create',
291
+ args: { kind: 'box', position: { x: 0, y: 0, z: 0 } },
292
+ },
293
+ {
294
+ path: 'materials.list',
295
+ command: 'materials.list',
296
+ args: { objectId: 'model:M' },
297
+ },
298
+ {
299
+ path: 'materials.update',
300
+ command: 'materials.update',
301
+ args: {
302
+ objectId: 'model:M',
303
+ slotId: 'root:0',
304
+ values: { roughness: 0.5 },
305
+ },
306
+ },
277
307
  { path: 'assets.list', command: 'assets.list' },
278
308
  { path: 'assets.get', command: 'assets.get', args: { assetId: 'asset-1' } },
279
309
  {
@@ -281,6 +311,11 @@ const FACADE_COMMANDS = [
281
311
  command: 'assets.importModel',
282
312
  args: { file: { name: 'm.glb' } },
283
313
  },
314
+ {
315
+ path: 'assets.importModels',
316
+ command: 'assets.importModels',
317
+ args: { files: [{ name: 'm.gltf' }, { name: 'm.bin' }] },
318
+ },
284
319
  {
285
320
  path: 'assets.rename',
286
321
  command: 'assets.rename',
@@ -468,6 +503,7 @@ const FACADE_COMMANDS = [
468
503
  /** CameraScene 生产绑定必须提供的完整 handler 集(命令侧 SCENE_UNAVAILABLE 的依据) */
469
504
  const HANDLER_KEYS = [
470
505
  'createModel',
506
+ 'createPrimitive',
471
507
  'createCamera',
472
508
  'setActiveCamera',
473
509
  'createAnchor',
@@ -602,6 +638,44 @@ describe('命名空间转发', () => {
602
638
  function commandOf(row) {
603
639
  return row.command;
604
640
  }
641
+ test('subscribe receives historic and selection changes and stops after unsubscribe', () => {
642
+ let notifyHistoric = () => { };
643
+ let notifySelection = () => { };
644
+ const unsubscribeSelection = jest.fn(() => {
645
+ notifySelection = () => { };
646
+ });
647
+ const runtime = createAgentRuntime({
648
+ project: {},
649
+ sheet: {},
650
+ library: {},
651
+ getStudio: () => ({
652
+ onSelectionChange: (listener) => {
653
+ notifySelection = listener;
654
+ return unsubscribeSelection;
655
+ },
656
+ }),
657
+ subscribeHistoric: (listener) => {
658
+ notifyHistoric = listener;
659
+ return () => {
660
+ notifyHistoric = () => { };
661
+ };
662
+ },
663
+ });
664
+ const controller = createCameraStudioApi();
665
+ controller.activate(runtime);
666
+ const listener = jest.fn();
667
+ const unsubscribe = controller.api.subscribe(listener);
668
+ notifyHistoric();
669
+ notifySelection();
670
+ expect(listener).toHaveBeenCalledTimes(2);
671
+ listener.mockClear();
672
+ unsubscribe();
673
+ notifyHistoric();
674
+ notifySelection();
675
+ expect(listener).not.toHaveBeenCalled();
676
+ expect(unsubscribeSelection).toHaveBeenCalledTimes(1);
677
+ controller.dispose();
678
+ });
605
679
  // ── suite 2:ready / dispose / remount ──────────────────────────
606
680
  describe('生命周期', () => {
607
681
  test('handlers 未绑定时不 ready:ready 与调用都挂起;绑定后 resolve,挂起调用继续执行', async () => {
@@ -15,6 +15,11 @@ export type AssetsApi = {
15
15
  }): Promise<{
16
16
  jobId: string;
17
17
  }>;
18
+ importModels(args: {
19
+ files: File[];
20
+ }): Promise<{
21
+ jobId: string;
22
+ }>;
18
23
  rename(args: {
19
24
  assetId: string;
20
25
  name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../../src/agent/commands/assets.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EAEb,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAC,aAAa,EAAkB,MAAM,WAAW,CAAA;AACxD,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,aAAa,CAAA;AACrD,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,YAAY,CAAA;AAI5C,8BAA8B;AAC9B,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,CAAC,IAAI,CAAC,EAAE;QAAC,IAAI,CAAC,EAAE,SAAS,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IACxD,GAAG,CAAC,IAAI,EAAE;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IACnD,WAAW,CAAC,IAAI,EAAE;QAAC,IAAI,EAAE,IAAI,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,CAAC,CAAA;IACzD,MAAM,CAAC,IAAI,EAAE;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IACpE,MAAM,CAAC,IAAI,EAAE;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,OAAO,EAAE,IAAI,CAAA;KAAC,CAAC,CAAA;CAC1D,CAAA;AA0GD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,CAsB7D;AAiFD,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,oBAAoB,EAC9B,OAAO,EAAE,YAAY,GACpB,IAAI,CAsLN"}
1
+ {"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../../src/agent/commands/assets.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EACV,SAAS,EACT,YAAY,EAEb,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAC,aAAa,EAAkB,MAAM,WAAW,CAAA;AACxD,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,aAAa,CAAA;AACrD,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,YAAY,CAAA;AAI5C,8BAA8B;AAC9B,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,CAAC,IAAI,CAAC,EAAE;QAAC,IAAI,CAAC,EAAE,SAAS,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IACxD,GAAG,CAAC,IAAI,EAAE;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IACnD,WAAW,CAAC,IAAI,EAAE;QAAC,IAAI,EAAE,IAAI,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,CAAC,CAAA;IACzD,YAAY,CAAC,IAAI,EAAE;QAAC,KAAK,EAAE,IAAI,EAAE,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,CAAC,CAAA;IAC7D,MAAM,CAAC,IAAI,EAAE;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;IACpE,MAAM,CAAC,IAAI,EAAE;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC;QAAC,OAAO,EAAE,IAAI,CAAA;KAAC,CAAC,CAAA;CAC1D,CAAA;AAgHD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa,CAsB7D;AA+GD,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,oBAAoB,EAC9B,OAAO,EAAE,YAAY,GACpB,IAAI,CA4ON"}
@@ -6,9 +6,9 @@
6
6
  * rename/delete 直等 Library 完成并返回更新后的条目 / {deleted:true};
7
7
  * 无 writable 源或条目属于只读源时返回 INVALID_STATE,不静默成功。
8
8
  *
9
- * importModel 是任务型命令(kind:job, completion:loaded):经
10
- * runtime.jobs.start 调用 importModelFile(file, library),任务结果即
11
- * ModelEntry;管线逐项落库,中途失败时 diff 出已创建条目,报
9
+ * importModel/importModels 是任务型命令(kind:job, completion:loaded):经
10
+ * runtime.jobs.start 调用单/多文件导入,任务结果分别为 ModelEntry 与
11
+ * ModelEntry[];管线逐项落库,中途失败时 diff 出已创建条目,报
12
12
  * PARTIAL_FAILURE(details 含 createdAssetIds 与失败项);Library 的
13
13
  * quota/network 等结构化错误分别保留为 STORAGE_ERROR / NETWORK_ERROR,
14
14
  * 不统一吞成 unknown。
@@ -19,6 +19,7 @@
19
19
  * fileRef 不许绕过传输适配直接进入 Library。
20
20
  */
21
21
  import { importModelFile } from '../../assetLibrary/importModelFile';
22
+ import { importModelFiles } from '../../assetLibrary/importModelFiles';
22
23
  import { LibraryError } from '../../assetLibrary/source';
23
24
  import { AgentApiError, asAgentApiError } from '../errors';
24
25
  import { requireObject, requireString } from '../validation';
@@ -36,7 +37,7 @@ const libraryEntrySchema = {
36
37
  },
37
38
  required: ['id', 'kind', 'name'],
38
39
  };
39
- /** ModelEntry 形状(importModel 的 jobResult) */
40
+ /** ModelEntry 形状(importModel/importModels 的 jobResult 元素) */
40
41
  const modelEntrySchema = {
41
42
  type: 'object',
42
43
  properties: {
@@ -88,24 +89,29 @@ const renameInput = {
88
89
  additionalProperties: false,
89
90
  };
90
91
  /** HTTP 传输形状:桥接把 fileId 水合成 File 后才进 validator */
91
- const importModelInput = {
92
+ const fileRefSchema = {
92
93
  type: 'object',
94
+ description: 'HTTP:经 POST /v1/files 上传后的引用;JS:直接传 hydrated File',
93
95
  properties: {
94
- file: {
95
- type: 'object',
96
- description: 'HTTP:经 POST /v1/files 上传后的引用;JS:直接传 hydrated File',
97
- properties: {
98
- fileId: { type: 'string', description: '桥接上传返回的文件 id' },
99
- fileName: { type: 'string' },
100
- mimeType: { type: 'string' },
101
- },
102
- required: ['fileId'],
103
- additionalProperties: false,
104
- },
96
+ fileId: { type: 'string', description: '桥接上传返回的文件 id' },
97
+ fileName: { type: 'string' },
98
+ mimeType: { type: 'string' },
105
99
  },
100
+ required: ['fileId'],
101
+ additionalProperties: false,
102
+ };
103
+ const importModelInput = {
104
+ type: 'object',
105
+ properties: { file: fileRefSchema },
106
106
  required: ['file'],
107
107
  additionalProperties: false,
108
108
  };
109
+ const importModelsInput = {
110
+ type: 'object',
111
+ properties: { files: { type: 'array', items: fileRefSchema } },
112
+ required: ['files'],
113
+ additionalProperties: false,
114
+ };
109
115
  const jobIdOutput = {
110
116
  type: 'object',
111
117
  properties: { jobId: { type: 'string' } },
@@ -149,6 +155,12 @@ function requireFile(value) {
149
155
  return value;
150
156
  throw new AgentApiError('INVALID_ARGUMENT', 'file 必须是 File(HTTP 调用先经桥接上传拿 fileId,由桥接水合成 File)');
151
157
  }
158
+ function requireFiles(value) {
159
+ if (!Array.isArray(value) || value.length === 0) {
160
+ throw new AgentApiError('INVALID_ARGUMENT', 'files 必须是非空 File 数组');
161
+ }
162
+ return value.map(requireFile);
163
+ }
152
164
  function requireWritable(library) {
153
165
  if (!library.canWrite) {
154
166
  throw new AgentApiError('INVALID_STATE', '资产库为纯查看器模式:未配置可写存储');
@@ -177,7 +189,7 @@ async function requireWritableEntry(library, assetId) {
177
189
  * 导入失败的结构化描述:导入管线逐项落库,中途失败时 diff 出本任务
178
190
  * 已创建的条目报 PARTIAL_FAILURE;无部分写入时按错误类型映射。
179
191
  */
180
- async function describeImportFailure(library, before, file, error) {
192
+ async function describeImportFailure(library, before, files, error) {
181
193
  let createdAssetIds = [];
182
194
  try {
183
195
  createdAssetIds = (await library.listEntries())
@@ -187,16 +199,34 @@ async function describeImportFailure(library, before, file, error) {
187
199
  catch {
188
200
  // 连列举都失败时不掩盖原始错误
189
201
  }
190
- const cause = error instanceof Error ? error.message : String(error);
202
+ const structured = error;
203
+ const failedItems = Array.isArray(structured?.failedItems)
204
+ ? structured.failedItems
205
+ : undefined;
206
+ const sourceError = structured?.cause ?? error;
207
+ const cause = sourceError instanceof Error ? sourceError.message : String(sourceError);
191
208
  if (createdAssetIds.length > 0) {
192
209
  return new AgentApiError('PARTIAL_FAILURE', `模型导入部分完成后中断: ${cause}`, {
193
210
  details: {
194
211
  createdAssetIds,
195
- failedItems: [{ file: file.name, message: cause }],
212
+ failedItems: failedItems ??
213
+ files.map((file) => ({ file: file.name, message: cause })),
196
214
  },
197
215
  });
198
216
  }
199
- return mapLibraryError(error);
217
+ const normalized = mapLibraryError(sourceError);
218
+ if (failedItems) {
219
+ return new AgentApiError(normalized.code, normalized.message, {
220
+ retryable: normalized.retryable,
221
+ details: {
222
+ ...(typeof normalized.details === 'object' && normalized.details
223
+ ? normalized.details
224
+ : {}),
225
+ failedItems,
226
+ },
227
+ });
228
+ }
229
+ return normalized;
200
230
  }
201
231
  export function registerAssetCommands(registry, runtime) {
202
232
  registry.register({
@@ -282,7 +312,49 @@ export function registerAssetCommands(registry, runtime) {
282
312
  return entry;
283
313
  }
284
314
  catch (error) {
285
- throw await describeImportFailure(library, before, args.file, error);
315
+ throw await describeImportFailure(library, before, [args.file], error);
316
+ }
317
+ },
318
+ });
319
+ return { jobId };
320
+ },
321
+ });
322
+ registry.register({
323
+ name: 'assets.importModels',
324
+ kind: 'job',
325
+ stateDomain: 'asset',
326
+ undoable: 'none',
327
+ completion: 'loaded',
328
+ cancelable: false,
329
+ inputSchema: importModelsInput,
330
+ outputSchema: jobIdOutput,
331
+ jobResultSchema: { type: 'array', items: modelEntrySchema },
332
+ errorCodes: [
333
+ 'API_DISPOSED',
334
+ 'INVALID_ARGUMENT',
335
+ 'INVALID_STATE',
336
+ 'STORAGE_ERROR',
337
+ 'NETWORK_ERROR',
338
+ 'PARTIAL_FAILURE',
339
+ 'INTERNAL_ERROR',
340
+ ],
341
+ validate: (value) => ({ files: requireFiles(requireObject(value).files) }),
342
+ execute: async (args) => {
343
+ runtime.assertActive();
344
+ requireWritable(runtime.library);
345
+ const library = runtime.library;
346
+ const { jobId } = runtime.jobs.start({
347
+ kind: 'assets.importModels',
348
+ cancelable: false,
349
+ execute: async (controls) => {
350
+ const before = new Set((await library.listEntries()).map((entry) => entry.id));
351
+ try {
352
+ const entries = await importModelFiles(args.files, library);
353
+ controls.update({ phase: 'loaded' });
354
+ return entries;
355
+ }
356
+ catch (error) {
357
+ throw await describeImportFailure(library, before, args.files, error);
286
358
  }
287
359
  },
288
360
  });
@@ -1,14 +1,20 @@
1
1
  /** 晚绑定:doMock 工厂只登记一次,各用例在 setup 里换实现 */
2
2
  let activeImportModelFile;
3
+ let activeImportModelFiles;
3
4
  jest.doMock('../../assetLibrary/importModelFile', () => ({
4
5
  __esModule: true,
5
6
  importModelFile: (...args) => activeImportModelFile(...args),
6
7
  }));
8
+ jest.doMock('../../assetLibrary/importModelFiles', () => ({
9
+ __esModule: true,
10
+ importModelFiles: (...args) => activeImportModelFiles(...args),
11
+ }));
7
12
  const cleanups = [];
8
13
  afterEach(() => {
9
14
  while (cleanups.length)
10
15
  cleanups.pop()();
11
16
  activeImportModelFile = undefined;
17
+ activeImportModelFiles = undefined;
12
18
  });
13
19
  const ctx = () => ({
14
20
  requestId: 'test',
@@ -95,6 +101,7 @@ function setup(options = {}) {
95
101
  m.registerAssetCommands(registry, runtime);
96
102
  m.registerJobCommands(registry, runtime);
97
103
  activeImportModelFile = jest.fn();
104
+ activeImportModelFiles = jest.fn();
98
105
  return { ...m, library, runtime, registry };
99
106
  }
100
107
  /** 轮询任务到终态(job manager 的 done 不经命令暴露,测试读快照) */
@@ -354,6 +361,52 @@ describe('assets.importModel', () => {
354
361
  expect(done.error?.code).toBe('INTERNAL_ERROR');
355
362
  });
356
363
  });
364
+ describe('assets.importModels', () => {
365
+ test('多个模型根成功时任务结果保持 ModelEntry[]', async () => {
366
+ const { registry, runtime, library } = setup();
367
+ const imported = [modelEntry('lib-a', 'a'), modelEntry('lib-b', 'b')];
368
+ activeImportModelFiles = jest.fn(async () => imported);
369
+ const files = [
370
+ new File(['a'], 'a.gltf'),
371
+ new File(['b'], 'b.gltf'),
372
+ new File(['bin'], 'shared.bin'),
373
+ ];
374
+ const { jobId } = (await registry.execute('assets.importModels', { files }, ctx()));
375
+ const done = await settleJob(runtime, jobId);
376
+ expect(activeImportModelFiles).toHaveBeenCalledWith(files, library);
377
+ expect(done).toMatchObject({
378
+ state: 'succeeded',
379
+ phase: 'loaded',
380
+ result: imported,
381
+ });
382
+ });
383
+ test('部分成功时只报告实际失败模型根,不误报成功项和伴随资源', async () => {
384
+ const { registry, runtime } = setup();
385
+ const partial = modelEntry('lib-a', 'a');
386
+ activeImportModelFiles = jest.fn(async (_files, library) => {
387
+ await library.putEntry(partial);
388
+ throw Object.assign(new Error('b 写入失败'), {
389
+ entries: [partial],
390
+ failedItems: [{ file: 'b.gltf', message: 'b 写入失败' }],
391
+ });
392
+ });
393
+ const files = [
394
+ new File(['a'], 'a.gltf'),
395
+ new File(['b'], 'b.gltf'),
396
+ new File(['bin'], 'shared.bin'),
397
+ ];
398
+ const { jobId } = (await registry.execute('assets.importModels', { files }, ctx()));
399
+ const done = await settleJob(runtime, jobId);
400
+ expect(done.state).toBe('failed');
401
+ expect(done.error).toMatchObject({
402
+ code: 'PARTIAL_FAILURE',
403
+ details: {
404
+ createdAssetIds: ['lib-a'],
405
+ failedItems: [{ file: 'b.gltf', message: 'b 写入失败' }],
406
+ },
407
+ });
408
+ });
409
+ });
357
410
  describe('jobs.get / jobs.cancel', () => {
358
411
  test('jobs.get 返回任务快照;未知任务 → JOB_NOT_FOUND', async () => {
359
412
  const { registry, runtime } = setup();
@@ -0,0 +1,18 @@
1
+ import type { AgentCommandRegistry } from '../registry';
2
+ import type { AgentRuntime } from '../runtime';
3
+ import type { MaterialSlot, MaterialValues } from '../../materials/types';
4
+ export type MaterialsApi = {
5
+ list(args: {
6
+ objectId: string;
7
+ }): Promise<MaterialSlot[]>;
8
+ update(args: {
9
+ objectId: string;
10
+ slotId: string;
11
+ values: Partial<MaterialValues>;
12
+ expectedRevision?: number;
13
+ }): Promise<{
14
+ revision: number;
15
+ }>;
16
+ };
17
+ export declare function registerMaterialCommands(registry: AgentCommandRegistry, runtime: AgentRuntime): void;
18
+ //# sourceMappingURL=materials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"materials.d.ts","sourceRoot":"","sources":["../../../src/agent/commands/materials.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,aAAa,CAAA;AACrD,OAAO,KAAK,EAAC,YAAY,EAAC,MAAM,YAAY,CAAA;AAI5C,OAAO,KAAK,EAAC,YAAY,EAAE,cAAc,EAAC,MAAM,uBAAuB,CAAA;AAEvE,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,CAAC,IAAI,EAAE;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAA;IACvD,MAAM,CAAC,IAAI,EAAE;QACX,QAAQ,EAAE,MAAM,CAAA;QAChB,MAAM,EAAE,MAAM,CAAA;QACd,MAAM,EAAE,OAAO,CAAC,cAAc,CAAC,CAAA;QAC/B,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,GAAG,OAAO,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC,CAAA;CAChC,CAAA;AAsHD,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,oBAAoB,EAC9B,OAAO,EAAE,YAAY,GACpB,IAAI,CAiHN"}
@@ -0,0 +1,192 @@
1
+ import { AgentApiError } from '../errors';
2
+ import { parseSceneObjectId } from '../objectIds';
3
+ import { resolveSceneSheetObject } from '../objectResolver';
4
+ import { assertStaticPropsWithLeafGuardWritable, setStaticPropsWithLeafGuard, } from '../propAccess';
5
+ import { requireFiniteNumber, requireObject, requireString } from '../validation';
6
+ import { collectMaterialSlots } from '../../materials/modelMaterials';
7
+ const materialValueProperties = {
8
+ color: { type: 'string', description: '#RRGGBB' },
9
+ metalness: { type: 'number', description: '0..1' },
10
+ roughness: { type: 'number', description: '0..1' },
11
+ emissive: { type: 'string', description: '#RRGGBB' },
12
+ opacity: { type: 'number', description: '0..1' },
13
+ mapBlobId: { type: 'string', description: '贴图 blob ID;空字符串恢复原图' },
14
+ normalMapBlobId: { type: 'string' },
15
+ roughnessMapBlobId: { type: 'string' },
16
+ metalnessMapBlobId: { type: 'string' },
17
+ emissiveMapBlobId: { type: 'string' },
18
+ };
19
+ const materialValuesSchema = {
20
+ type: 'object',
21
+ properties: materialValueProperties,
22
+ additionalProperties: false,
23
+ };
24
+ const materialSlotSchema = {
25
+ type: 'object',
26
+ properties: {
27
+ slotId: { type: 'string' },
28
+ slotKey: { type: 'string' },
29
+ meshName: { type: 'string' },
30
+ materialName: { type: 'string' },
31
+ values: {
32
+ ...materialValuesSchema,
33
+ required: Object.keys(materialValueProperties),
34
+ },
35
+ },
36
+ required: ['slotId', 'slotKey', 'meshName', 'materialName', 'values'],
37
+ };
38
+ function optionalExpectedRevision(value) {
39
+ return value === undefined
40
+ ? undefined
41
+ : requireFiniteNumber(value, 'expectedRevision');
42
+ }
43
+ function validateMaterialValues(value) {
44
+ const values = requireObject(value);
45
+ const entries = Object.entries(values);
46
+ if (entries.length === 0) {
47
+ throw new AgentApiError('INVALID_ARGUMENT', 'values 不能为空对象');
48
+ }
49
+ for (const [key, input] of entries) {
50
+ if (!(key in materialValueProperties)) {
51
+ throw new AgentApiError('INVALID_ARGUMENT', `未知材质属性: ${key}`);
52
+ }
53
+ if (['metalness', 'roughness', 'opacity'].includes(key)) {
54
+ const number = requireFiniteNumber(input, `values.${key}`);
55
+ if (number < 0 || number > 1) {
56
+ throw new AgentApiError('INVALID_ARGUMENT', `values.${key} 必须在 0..1 范围内`);
57
+ }
58
+ }
59
+ else if (key === 'color' || key === 'emissive') {
60
+ if (typeof input !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(input)) {
61
+ throw new AgentApiError('INVALID_ARGUMENT', `values.${key} 必须是 #RRGGBB 颜色`);
62
+ }
63
+ }
64
+ else if (typeof input !== 'string') {
65
+ throw new AgentApiError('INVALID_ARGUMENT', `values.${key} 必须是贴图 blob ID 或空字符串`);
66
+ }
67
+ }
68
+ return values;
69
+ }
70
+ function mountedModel(runtime, objectId) {
71
+ const sheetObject = resolveSceneSheetObject(runtime.sheet, objectId, 'model');
72
+ if (!sheetObject) {
73
+ throw new AgentApiError('INVALID_STATE', `对象未挂载,无法访问材质: ${objectId}`, { details: { objectId } });
74
+ }
75
+ const getModelPlacement = runtime.handlers.getModelPlacement;
76
+ if (!getModelPlacement) {
77
+ throw new AgentApiError('SCENE_UNAVAILABLE', 'Scene Runtime 未绑定 getModelPlacement,无法访问模型实例');
78
+ }
79
+ const { key } = parseSceneObjectId(objectId);
80
+ const placement = getModelPlacement({ key });
81
+ if (!placement) {
82
+ throw new AgentApiError('INVALID_STATE', `模型实例未挂载: ${objectId}`, {
83
+ details: { objectId },
84
+ });
85
+ }
86
+ return { sheetObject, instance: placement.instance };
87
+ }
88
+ function evaluatedOverrides(sheetObject) {
89
+ const value = sheetObject.value;
90
+ return (value.materialOverrides ?? {});
91
+ }
92
+ export function registerMaterialCommands(registry, runtime) {
93
+ registry.register({
94
+ name: 'materials.list',
95
+ kind: 'query',
96
+ stateDomain: 'project',
97
+ undoable: 'none',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: { objectId: { type: 'string' } },
101
+ required: ['objectId'],
102
+ additionalProperties: false,
103
+ },
104
+ outputSchema: { type: 'array', items: materialSlotSchema },
105
+ errorCodes: [
106
+ 'API_DISPOSED',
107
+ 'INVALID_ARGUMENT',
108
+ 'INVALID_ID_KIND',
109
+ 'OBJECT_NOT_FOUND',
110
+ 'INVALID_STATE',
111
+ 'SCENE_UNAVAILABLE',
112
+ ],
113
+ validate: (value) => ({
114
+ objectId: requireString(requireObject(value), 'objectId'),
115
+ }),
116
+ execute: async ({ objectId }) => {
117
+ runtime.assertActive();
118
+ const { sheetObject, instance } = mountedModel(runtime, objectId);
119
+ const overrides = evaluatedOverrides(sheetObject);
120
+ return collectMaterialSlots(instance).map((slot) => ({
121
+ ...slot,
122
+ values: { ...slot.values, ...overrides[slot.slotKey] },
123
+ }));
124
+ },
125
+ });
126
+ registry.register({
127
+ name: 'materials.update',
128
+ kind: 'mutation',
129
+ stateDomain: 'project',
130
+ undoable: 'single-step',
131
+ completion: 'committed',
132
+ inputSchema: {
133
+ type: 'object',
134
+ properties: {
135
+ objectId: { type: 'string' },
136
+ slotId: { type: 'string' },
137
+ values: materialValuesSchema,
138
+ expectedRevision: { type: 'number' },
139
+ },
140
+ required: ['objectId', 'slotId', 'values'],
141
+ additionalProperties: false,
142
+ },
143
+ outputSchema: {
144
+ type: 'object',
145
+ properties: { revision: { type: 'number' } },
146
+ required: ['revision'],
147
+ },
148
+ errorCodes: [
149
+ 'API_DISPOSED',
150
+ 'INVALID_ARGUMENT',
151
+ 'INVALID_ID_KIND',
152
+ 'OBJECT_NOT_FOUND',
153
+ 'INVALID_STATE',
154
+ 'REVISION_CONFLICT',
155
+ 'SCENE_UNAVAILABLE',
156
+ ],
157
+ validate: (value) => {
158
+ const args = requireObject(value);
159
+ return {
160
+ objectId: requireString(args, 'objectId'),
161
+ slotId: requireString(args, 'slotId'),
162
+ values: validateMaterialValues(args.values),
163
+ expectedRevision: optionalExpectedRevision(args.expectedRevision),
164
+ };
165
+ },
166
+ execute: async (args) => {
167
+ runtime.assertActive();
168
+ const { sheetObject, instance } = mountedModel(runtime, args.objectId);
169
+ const slot = collectMaterialSlots(instance).find((candidate) => candidate.slotId === args.slotId);
170
+ if (!slot) {
171
+ throw new AgentApiError('INVALID_ARGUMENT', `材质槽不存在: ${args.slotId}`, { details: { objectId: args.objectId, slotId: args.slotId } });
172
+ }
173
+ const current = evaluatedOverrides(sheetObject);
174
+ const materialOverrides = {
175
+ ...current,
176
+ [slot.slotKey]: {
177
+ ...slot.values,
178
+ ...current[slot.slotKey],
179
+ ...args.values,
180
+ },
181
+ };
182
+ const patch = { materialOverrides };
183
+ const guardedPropPaths = Object.keys(args.values).map((property) => `materialOverrides.${slot.slotKey}.${property}`);
184
+ assertStaticPropsWithLeafGuardWritable(sheetObject, patch, guardedPropPaths);
185
+ if (args.expectedRevision !== undefined) {
186
+ runtime.assertRevision(args.expectedRevision);
187
+ }
188
+ setStaticPropsWithLeafGuard(sheetObject, patch, guardedPropPaths);
189
+ return { revision: await runtime.nextRevision() };
190
+ },
191
+ });
192
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=materials.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"materials.test.d.ts","sourceRoot":"","sources":["../../../src/agent/commands/materials.test.ts"],"names":[],"mappings":""}