图片画布接入 3D 资源:新增 model3d 类别、3D 预览与 3D 生成入口
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 16s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 16s
Project CI / Native shell tests (pull_request) Failing after 16s
Project CI / Frontend tests (pull_request) Failing after 7s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s
- 后端资源投影按 asset_kind 分叉,新增 project_editor_client_media_src 与 EDITOR_MODEL3D_ASSET_KIND:model3d 只投影预览图,模型本体只留 objectKey,预览缺失才回落历史口径 - tripo3d 存储新增按字节魔数识别模型 content_type 与扩展名映射,不采信 provider 声明与下载响应头 - editor_project_storage 把 model3d 加入 EDITOR_CANVAS_ASSET_KINDS 白名单 - 前端 CanvasAssetKind 与 image-canvas-core 类型新增 model3d:画布卡片渲染预览图或 3D 占位,类型角标新增「3D模型」,覆盖资格只在它与空类别之间成立 - 新增 model3d-preview 子目录(Model3dViewerModal / Model3dViewerModel),Tailwind 内联、不新增 CSS,失败原因分超限 / 格式不可识别 / 无 WebGL2 三类展示 - 选中工具条新增只读 preview-model3d 能力位,仅 objectKey 为空时置灰;打开预览同时选中图层并暂停画布快捷键与舞台交互 - 查看器包源对象 format 改为可选,新增「声明 Content-Type → 字节魔数 → 地址扩展名」三级格式判定并导出支持格式清单 - 导出工作流新增 3d_models/ 目录,模型下载按真实响应头 MIME 定扩展名、对象键扩展名兜底 - 新增 3D 生成入口:底部工具栏浮动子选项与文生 / 图生 3D 面板,落 projectResource + canvasCompletion,复用占位框与任务侧栏 - 3D 定价只读实时定价查询,段缺失即入口不可提交;幂等键由前端铸造,用户重试必须换键 - 新增 ADR 0003 / 0004 与实施计划、里程碑文档;更新 Tripo 技术方案、后端架构、CONTEXT 术语、decision-log 与 pitfalls - .gitignore 忽略仓库根目录手工下载的 3D 样例模型
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Model3dTextureQuality } from '../../../../packages/shared/src/contracts/model3d';
|
||||
import type {
|
||||
Model3dAddOnPriceKey,
|
||||
Model3dPricingConfig,
|
||||
} from '../../../services/image-editor/editorProjectClient';
|
||||
import type { GenerateDialogState } from '../ImageCanvasEditorTypes';
|
||||
import {
|
||||
applyModel3dPromptChange,
|
||||
applyModel3dReferenceRemoval,
|
||||
applyModel3dSwitchChange,
|
||||
applyModel3dTierChange,
|
||||
createModel3dGenerationDraftFields,
|
||||
mintModel3dAttemptNonce,
|
||||
MODEL3D_PRICING_UNAVAILABLE_REASON,
|
||||
type Model3dGenerationMode,
|
||||
refreshModel3dAttemptNonce,
|
||||
resolveModel3dAddOnKeys,
|
||||
resolveModel3dGenerationParams,
|
||||
resolveModel3dGenerationQuote,
|
||||
resolveModel3dPrice,
|
||||
} from './Model3dGenerationFormModel';
|
||||
|
||||
function createDialog(
|
||||
overrides: Partial<GenerateDialogState> = {},
|
||||
): GenerateDialogState {
|
||||
return {
|
||||
id: 'dialog-3d',
|
||||
...createModel3dGenerationDraftFields('model3d-text-to-model'),
|
||||
...overrides,
|
||||
} as GenerateDialogState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 与后端 `api-server::tripo3d::pricing::model3d_add_ons` 逐条对齐的期望值:
|
||||
* 前端只有这一份映射,价格差异必须在这里被用例挡住。
|
||||
*/
|
||||
function expectedAddOns({
|
||||
texture,
|
||||
textureQuality,
|
||||
geometryQuality,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
generateParts,
|
||||
}: {
|
||||
texture: boolean;
|
||||
textureQuality: Model3dTextureQuality;
|
||||
geometryQuality: 'standard' | 'detailed';
|
||||
quad: boolean;
|
||||
smartLowPoly: boolean;
|
||||
generateParts: boolean;
|
||||
}): Model3dAddOnPriceKey[] {
|
||||
return [
|
||||
...(texture && textureQuality === 'detailed' ? ['hdTexture' as const] : []),
|
||||
...(texture && textureQuality === 'extreme'
|
||||
? ['ultraTexture' as const]
|
||||
: []),
|
||||
...(geometryQuality === 'detailed' ? ['hdGeometry' as const] : []),
|
||||
...(quad ? ['quadMesh' as const] : []),
|
||||
...(smartLowPoly ? ['smartLowPoly' as const] : []),
|
||||
...(generateParts ? ['generateParts' as const] : []),
|
||||
];
|
||||
}
|
||||
|
||||
const PRICING: Model3dPricingConfig = {
|
||||
basePrices: {
|
||||
'text-to-model': {
|
||||
'v3.1-20260211': { noTexture: 100, texture: 200 },
|
||||
},
|
||||
'image-to-model': {
|
||||
'v3.1-20260211': { noTexture: 150, texture: 250 },
|
||||
},
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 10,
|
||||
ultraTexture: 20,
|
||||
hdGeometry: 30,
|
||||
quadMesh: 40,
|
||||
smartLowPoly: 50,
|
||||
generateParts: 60,
|
||||
},
|
||||
};
|
||||
|
||||
describe('resolveModel3dGenerationParams', () => {
|
||||
it('每个档位映射成完整参数,档位与开关不留给 provider 隐式默认', () => {
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
params: {
|
||||
texture: true,
|
||||
textureQuality: 'standard',
|
||||
geometryQuality: 'standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: false,
|
||||
pbr: false,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
tier: 'textured-detailed',
|
||||
quad: true,
|
||||
smartLowPoly: true,
|
||||
generateParts: false,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
params: {
|
||||
texture: true,
|
||||
textureQuality: 'detailed',
|
||||
geometryQuality: 'detailed',
|
||||
quad: true,
|
||||
smartLowPoly: true,
|
||||
generateParts: false,
|
||||
pbr: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('纯几何档位没有贴图档位,pbr 显式为 false', () => {
|
||||
const result = resolveModel3dGenerationParams({
|
||||
tier: 'geometry-only',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: true,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok && result.params).toMatchObject({
|
||||
texture: false,
|
||||
textureQuality: null,
|
||||
geometryQuality: 'standard',
|
||||
generateParts: true,
|
||||
pbr: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('贴图档位不允许分件,这是后端也会拒的组合', () => {
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
tier: 'textured-standard',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: true,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveModel3dGenerationParams({
|
||||
tier: 'textured-detailed',
|
||||
quad: false,
|
||||
smartLowPoly: false,
|
||||
generateParts: true,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel3dAddOnKeys', () => {
|
||||
it('与后端 add-on 判定在全部档位与开关组合上一致', () => {
|
||||
for (const tier of [
|
||||
'textured-standard',
|
||||
'textured-detailed',
|
||||
'geometry-only',
|
||||
] as const) {
|
||||
for (const quad of [false, true]) {
|
||||
for (const smartLowPoly of [false, true]) {
|
||||
for (const generateParts of [false, true]) {
|
||||
const result = resolveModel3dGenerationParams({
|
||||
tier,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
generateParts,
|
||||
});
|
||||
if (!result.ok) {
|
||||
continue;
|
||||
}
|
||||
expect(resolveModel3dAddOnKeys(result.params)).toEqual(
|
||||
expectedAddOns({
|
||||
texture: result.params.texture,
|
||||
textureQuality: result.params.textureQuality ?? 'standard',
|
||||
geometryQuality: result.params.geometryQuality,
|
||||
quad,
|
||||
smartLowPoly,
|
||||
generateParts,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel3dPrice', () => {
|
||||
const paramsOf = (
|
||||
tier: 'textured-standard' | 'textured-detailed' | 'geometry-only',
|
||||
switches: {
|
||||
quad?: boolean;
|
||||
smartLowPoly?: boolean;
|
||||
generateParts?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const result = resolveModel3dGenerationParams({
|
||||
tier,
|
||||
quad: switches.quad ?? false,
|
||||
smartLowPoly: switches.smartLowPoly ?? false,
|
||||
generateParts: switches.generateParts ?? false,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error('档位组合必须合法');
|
||||
}
|
||||
return result.params;
|
||||
};
|
||||
|
||||
it('底价按端点与是否有贴图取,add-on 逐项相加', () => {
|
||||
expect(
|
||||
resolveModel3dPrice({
|
||||
pricing: PRICING,
|
||||
endpoint: 'image-to-model',
|
||||
modelVersion: 'v3.1-20260211',
|
||||
params: paramsOf('textured-standard'),
|
||||
}),
|
||||
).toEqual({ ok: true, price: 250 });
|
||||
expect(
|
||||
resolveModel3dPrice({
|
||||
pricing: PRICING,
|
||||
endpoint: 'text-to-model',
|
||||
modelVersion: 'v3.1-20260211',
|
||||
params: paramsOf('geometry-only'),
|
||||
}),
|
||||
).toEqual({ ok: true, price: 100 });
|
||||
});
|
||||
|
||||
it('缺段、缺底价、缺 add-on 一律不可提交,不按 0 放行', () => {
|
||||
expect(
|
||||
resolveModel3dPrice({
|
||||
pricing: null,
|
||||
endpoint: 'text-to-model',
|
||||
params: paramsOf('textured-standard'),
|
||||
modelVersion: 'v3.1-20260211',
|
||||
}),
|
||||
).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON });
|
||||
expect(
|
||||
resolveModel3dPrice({
|
||||
pricing: { ...PRICING, basePrices: null },
|
||||
endpoint: 'text-to-model',
|
||||
modelVersion: 'v3.1-20260211',
|
||||
params: paramsOf('textured-standard'),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveModel3dPrice({
|
||||
// 纯几何 + 分件只叠 generateParts 一个 add-on,缺的就是它。
|
||||
pricing: { ...PRICING, addOnPrices: { quadMesh: 40 } },
|
||||
endpoint: 'text-to-model',
|
||||
modelVersion: 'v3.1-20260211',
|
||||
params: paramsOf('geometry-only', { generateParts: true }),
|
||||
}),
|
||||
).toEqual({ ok: false, reason: '3D 模型定价缺少 generateParts 价格' });
|
||||
expect(
|
||||
resolveModel3dPrice({
|
||||
pricing: PRICING,
|
||||
endpoint: 'text-to-model',
|
||||
modelVersion: 'v3.0-20250812',
|
||||
params: paramsOf('textured-standard'),
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel3dGenerationQuote', () => {
|
||||
it('文生与图生各取自己的端点价格', () => {
|
||||
expect(resolveModel3dGenerationQuote(createDialog(), PRICING)).toEqual({
|
||||
ok: true,
|
||||
price: 200,
|
||||
endpoint: 'text-to-model',
|
||||
});
|
||||
expect(
|
||||
resolveModel3dGenerationQuote(
|
||||
createDialog({ mode: 'model3d-image-to-model' }),
|
||||
PRICING,
|
||||
),
|
||||
).toEqual({ ok: true, price: 250, endpoint: 'image-to-model' });
|
||||
});
|
||||
|
||||
it('定价段缺失时两个子项都拿不到价格', () => {
|
||||
for (const mode of [
|
||||
'model3d-text-to-model',
|
||||
'model3d-image-to-model',
|
||||
] as Model3dGenerationMode[]) {
|
||||
expect(
|
||||
resolveModel3dGenerationQuote(createDialog({ mode }), null),
|
||||
).toEqual({ ok: false, reason: MODEL3D_PRICING_UNAVAILABLE_REASON });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('3D 生成面板的参数变更', () => {
|
||||
it('换档位时贴图档位强制关掉分件,并换代次', () => {
|
||||
const geometryDialog = createDialog({
|
||||
model3dTier: 'geometry-only',
|
||||
model3dGenerateParts: true,
|
||||
});
|
||||
const textured = applyModel3dTierChange(
|
||||
geometryDialog,
|
||||
'textured-detailed',
|
||||
);
|
||||
expect(textured.model3dGenerateParts).toBe(false);
|
||||
expect(textured.model3dAttemptNonce).not.toBe(
|
||||
geometryDialog.model3dAttemptNonce,
|
||||
);
|
||||
|
||||
const back = applyModel3dTierChange(textured, 'geometry-only');
|
||||
expect(back.model3dTier).toBe('geometry-only');
|
||||
expect(back.model3dAttemptNonce).not.toBe(textured.model3dAttemptNonce);
|
||||
});
|
||||
|
||||
it('改开关、改提示词、主动重试都会换代次,并清掉失败态', () => {
|
||||
const failedDialog = createDialog({
|
||||
status: 'failed',
|
||||
errorMessage: '3D 模型生成失败,请稍后重试。',
|
||||
});
|
||||
expect(
|
||||
applyModel3dSwitchChange(failedDialog, { model3dQuad: true }),
|
||||
).toMatchObject({ model3dQuad: true, status: 'idle' });
|
||||
expect(
|
||||
applyModel3dSwitchChange(failedDialog, { model3dQuad: true })
|
||||
.model3dAttemptNonce,
|
||||
).not.toBe(failedDialog.model3dAttemptNonce);
|
||||
expect(applyModel3dPromptChange(failedDialog, '一把木椅')).toMatchObject({
|
||||
prompt: '一把木椅',
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
});
|
||||
expect(
|
||||
applyModel3dPromptChange(failedDialog, '一把木椅').model3dAttemptNonce,
|
||||
).not.toBe(failedDialog.model3dAttemptNonce);
|
||||
expect(refreshModel3dAttemptNonce(failedDialog)).toMatchObject({
|
||||
status: 'idle',
|
||||
errorMessage: undefined,
|
||||
});
|
||||
expect(
|
||||
refreshModel3dAttemptNonce(failedDialog).model3dAttemptNonce,
|
||||
).not.toBe(failedDialog.model3dAttemptNonce);
|
||||
});
|
||||
|
||||
it('删掉参考图回到空槽位并清掉失败态', () => {
|
||||
const dialog = createDialog({
|
||||
mode: 'model3d-image-to-model',
|
||||
status: 'failed',
|
||||
errorMessage: '3D 模型生成失败,请稍后重试。',
|
||||
generationReferences: [
|
||||
{ id: 'reference-1', label: '参考图', src: 'data:image/png;base64,1' },
|
||||
{ id: 'reference-2', label: '参考图', src: 'data:image/png;base64,2' },
|
||||
],
|
||||
});
|
||||
|
||||
const next = applyModel3dReferenceRemoval(dialog, 'reference-1');
|
||||
|
||||
expect(next.generationReferences?.map((reference) => reference.id)).toEqual(
|
||||
['reference-2'],
|
||||
);
|
||||
expect(next).toMatchObject({ status: 'idle', errorMessage: undefined });
|
||||
expect(
|
||||
applyModel3dReferenceRemoval(next, 'reference-2').generationReferences,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('每次铸造的代次互不相同', () => {
|
||||
const nonces = new Set(
|
||||
Array.from({ length: 20 }, () => mintModel3dAttemptNonce()),
|
||||
);
|
||||
expect(nonces.size).toBe(20);
|
||||
for (const nonce of nonces) {
|
||||
expect(nonce).toMatch(/^model3d-[0-9a-zA-Z-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user