@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.
- package/dist/CameraScene.d.ts +1 -1
- package/dist/CameraScene.d.ts.map +1 -1
- package/dist/CameraScene.js +36 -6
- package/dist/PlacementManager.d.ts +15 -1
- package/dist/PlacementManager.d.ts.map +1 -1
- package/dist/PlacementManager.js +123 -14
- package/dist/agent/api.d.ts +5 -0
- package/dist/agent/api.d.ts.map +1 -1
- package/dist/agent/api.js +25 -0
- package/dist/agent/api.test.js +75 -1
- package/dist/agent/commands/assets.d.ts +5 -0
- package/dist/agent/commands/assets.d.ts.map +1 -1
- package/dist/agent/commands/assets.js +93 -21
- package/dist/agent/commands/assets.test.js +53 -0
- package/dist/agent/commands/materials.d.ts +18 -0
- package/dist/agent/commands/materials.d.ts.map +1 -0
- package/dist/agent/commands/materials.js +192 -0
- package/dist/agent/commands/materials.test.d.ts +2 -0
- package/dist/agent/commands/materials.test.d.ts.map +1 -0
- package/dist/agent/commands/materials.test.js +294 -0
- package/dist/agent/commands/modelRecovery.test.d.ts +2 -0
- package/dist/agent/commands/modelRecovery.test.d.ts.map +1 -0
- package/dist/agent/commands/modelRecovery.test.js +122 -0
- package/dist/agent/commands/models.d.ts +13 -0
- package/dist/agent/commands/models.d.ts.map +1 -1
- package/dist/agent/commands/models.js +98 -1
- package/dist/agent/commands/primitives.d.ts +27 -0
- package/dist/agent/commands/primitives.d.ts.map +1 -0
- package/dist/agent/commands/primitives.js +117 -0
- package/dist/agent/commands/primitives.test.d.ts +2 -0
- package/dist/agent/commands/primitives.test.d.ts.map +1 -0
- package/dist/agent/commands/primitives.test.js +135 -0
- package/dist/agent/expectedCommands.d.ts +1 -1
- package/dist/agent/expectedCommands.d.ts.map +1 -1
- package/dist/agent/expectedCommands.js +6 -0
- package/dist/agent/propAccess.d.ts +2 -0
- package/dist/agent/propAccess.d.ts.map +1 -1
- package/dist/agent/propAccess.js +21 -0
- package/dist/agent/registerCommands.d.ts.map +1 -1
- package/dist/agent/registerCommands.js +5 -1
- package/dist/agent/runtime/objectRuntime.d.ts +2 -0
- package/dist/agent/runtime/objectRuntime.d.ts.map +1 -1
- package/dist/agent/runtime/objectRuntime.js +15 -1
- package/dist/agent/runtime.d.ts +1 -0
- package/dist/agent/runtime.d.ts.map +1 -1
- package/dist/agent/runtime.js +2 -0
- package/dist/assetLibrary/dispose.d.ts +4 -2
- package/dist/assetLibrary/dispose.d.ts.map +1 -1
- package/dist/assetLibrary/dispose.js +7 -5
- package/dist/assetLibrary/gltfLoader.d.ts +2 -1
- package/dist/assetLibrary/gltfLoader.d.ts.map +1 -1
- package/dist/assetLibrary/gltfLoader.js +2 -2
- package/dist/assetLibrary/gltfResourceSet.d.ts +6 -0
- package/dist/assetLibrary/gltfResourceSet.d.ts.map +1 -0
- package/dist/assetLibrary/gltfResourceSet.js +27 -0
- package/dist/assetLibrary/gltfResourceSet.test.d.ts +2 -0
- package/dist/assetLibrary/gltfResourceSet.test.d.ts.map +1 -0
- package/dist/assetLibrary/gltfResourceSet.test.js +40 -0
- package/dist/assetLibrary/importModelFile.d.ts +5 -0
- package/dist/assetLibrary/importModelFile.d.ts.map +1 -1
- package/dist/assetLibrary/importModelFile.js +3 -16
- package/dist/assetLibrary/importModelFiles.d.ts +15 -0
- package/dist/assetLibrary/importModelFiles.d.ts.map +1 -0
- package/dist/assetLibrary/importModelFiles.js +58 -0
- package/dist/assetLibrary/importModelFiles.test.d.ts +2 -0
- package/dist/assetLibrary/importModelFiles.test.d.ts.map +1 -0
- package/dist/assetLibrary/importModelFiles.test.js +217 -0
- package/dist/assetLibrary/importPipeline.d.ts +15 -1
- package/dist/assetLibrary/importPipeline.d.ts.map +1 -1
- package/dist/assetLibrary/importPipeline.js +74 -38
- package/dist/assetLibrary/importPipeline.test.js +25 -0
- package/dist/assetLibrary/library.d.ts +1 -0
- package/dist/assetLibrary/library.d.ts.map +1 -1
- package/dist/assetLibrary/library.js +22 -1
- package/dist/assetLibrary/library.test.js +76 -0
- package/dist/bootstrap.d.ts +2 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +69 -7
- package/dist/bootstrap.test.js +111 -0
- package/dist/config.d.ts +10 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/hostFlows.d.ts +1 -0
- package/dist/hostFlows.d.ts.map +1 -1
- package/dist/hostFlows.js +9 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/materials/modelMaterials.d.ts +19 -0
- package/dist/materials/modelMaterials.d.ts.map +1 -0
- package/dist/materials/modelMaterials.js +253 -0
- package/dist/materials/modelMaterials.test.d.ts +2 -0
- package/dist/materials/modelMaterials.test.d.ts.map +1 -0
- package/dist/materials/modelMaterials.test.js +188 -0
- package/dist/materials/types.d.ts +20 -0
- package/dist/materials/types.d.ts.map +1 -0
- package/dist/materials/types.js +1 -0
- package/dist/materials/useModelMaterials.d.ts +10 -0
- package/dist/materials/useModelMaterials.d.ts.map +1 -0
- package/dist/materials/useModelMaterials.js +26 -0
- package/dist/materials/useModelMaterials.test.d.ts +2 -0
- package/dist/materials/useModelMaterials.test.d.ts.map +1 -0
- package/dist/materials/useModelMaterials.test.js +75 -0
- package/dist/poses.d.ts +38 -0
- package/dist/poses.d.ts.map +1 -0
- package/dist/poses.js +41 -0
- package/dist/poses.test.d.ts +2 -0
- package/dist/poses.test.d.ts.map +1 -0
- package/dist/poses.test.js +38 -0
- package/dist/primitives.d.ts +5 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +15 -0
- package/dist/primitives.test.d.ts +2 -0
- package/dist/primitives.test.d.ts.map +1 -0
- package/dist/primitives.test.js +27 -0
- package/dist/reset.realStudio.test.d.ts +2 -0
- package/dist/reset.realStudio.test.d.ts.map +1 -0
- package/dist/reset.realStudio.test.js +60 -0
- package/dist/studioRoot.d.ts +4 -0
- package/dist/studioRoot.d.ts.map +1 -0
- package/dist/studioRoot.js +36 -0
- package/dist/studioRoot.test.d.ts +2 -0
- package/dist/studioRoot.test.d.ts.map +1 -0
- package/dist/studioRoot.test.js +62 -0
- package/dist/useCameraStudio.d.ts.map +1 -1
- package/dist/useCameraStudio.js +3 -2
- package/package.json +9 -1
package/dist/agent/api.test.js
CHANGED
|
@@ -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
|
-
*
|
|
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 () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../../src/agent/commands/assets.ts"],"names":[],"mappings":"
|
|
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
|
|
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
|
|
92
|
+
const fileRefSchema = {
|
|
92
93
|
type: 'object',
|
|
94
|
+
description: 'HTTP:经 POST /v1/files 上传后的引用;JS:直接传 hydrated File',
|
|
93
95
|
properties: {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
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
|
|
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:
|
|
212
|
+
failedItems: failedItems ??
|
|
213
|
+
files.map((file) => ({ file: file.name, message: cause })),
|
|
196
214
|
},
|
|
197
215
|
});
|
|
198
216
|
}
|
|
199
|
-
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"materials.test.d.ts","sourceRoot":"","sources":["../../../src/agent/commands/materials.test.ts"],"names":[],"mappings":""}
|