diff --git a/backend/src/ai-3d/ai-3d.service.ts b/backend/src/ai-3d/ai-3d.service.ts index e2330f2..d42f1cb 100644 --- a/backend/src/ai-3d/ai-3d.service.ts +++ b/backend/src/ai-3d/ai-3d.service.ts @@ -62,6 +62,10 @@ export class AI3DService { const externalTaskId = await this.ai3dProvider.submitTask( dto.inputType, dto.inputContent, + { + generateType: dto.generateType, + faceCount: dto.faceCount, + }, ); // 4. 更新状态为处理中 diff --git a/backend/src/ai-3d/dto/create-task.dto.ts b/backend/src/ai-3d/dto/create-task.dto.ts index cb0ce08..6b64628 100644 --- a/backend/src/ai-3d/dto/create-task.dto.ts +++ b/backend/src/ai-3d/dto/create-task.dto.ts @@ -1,4 +1,22 @@ -import { IsString, IsIn, IsNotEmpty, MaxLength } from 'class-validator'; +import { + IsString, + IsIn, + IsNotEmpty, + MaxLength, + IsOptional, + IsInt, + Min, + Max, +} from 'class-validator'; + +/** + * 模型生成类型 + * Normal: 带纹理 + * LowPoly: 低多边形 + * Geometry: 白模 + * Sketch: 草图 + */ +export type GenerateType = 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch'; export class CreateTaskDto { @IsString() @@ -9,4 +27,17 @@ export class CreateTaskDto { @IsNotEmpty({ message: '输入内容不能为空' }) @MaxLength(2000, { message: '输入内容最多2000个字符' }) inputContent: string; + + @IsOptional() + @IsString() + @IsIn(['Normal', 'LowPoly', 'Geometry', 'Sketch'], { + message: '模型类型必须是 Normal、LowPoly、Geometry 或 Sketch', + }) + generateType?: GenerateType; + + @IsOptional() + @IsInt({ message: '模型面数必须是整数' }) + @Min(10000, { message: '模型面数最小为10000' }) + @Max(1500000, { message: '模型面数最大为1500000' }) + faceCount?: number; } diff --git a/backend/src/ai-3d/providers/ai-3d-provider.interface.ts b/backend/src/ai-3d/providers/ai-3d-provider.interface.ts index d72336d..4eb80b9 100644 --- a/backend/src/ai-3d/providers/ai-3d-provider.interface.ts +++ b/backend/src/ai-3d/providers/ai-3d-provider.interface.ts @@ -11,6 +11,16 @@ export interface AI3DGenerateResult { errorMessage?: string; // 错误信息 } +/** + * 模型生成配置选项 + */ +export interface AI3DGenerateOptions { + /** 模型生成类型:Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */ + generateType?: 'Normal' | 'LowPoly' | 'Geometry' | 'Sketch'; + /** 模型面数:10000-1500000,默认500000 */ + faceCount?: number; +} + /** * AI 3D 服务提供者接口 * 支持 Mock、腾讯混元、Meshy 等实现 @@ -20,11 +30,13 @@ export interface AI3DProvider { * 提交生成任务 * @param inputType 输入类型:text | image * @param inputContent 输入内容:文字描述或图片URL + * @param options 可选配置项(仅文生3D支持) * @returns 外部任务ID */ submitTask( inputType: 'text' | 'image', inputContent: string, + options?: AI3DGenerateOptions, ): Promise; /** diff --git a/backend/src/ai-3d/providers/hunyuan.provider.ts b/backend/src/ai-3d/providers/hunyuan.provider.ts index e4f1746..1a8f559 100644 --- a/backend/src/ai-3d/providers/hunyuan.provider.ts +++ b/backend/src/ai-3d/providers/hunyuan.provider.ts @@ -1,7 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import { AI3DProvider, AI3DGenerateResult } from './ai-3d-provider.interface'; +import { + AI3DProvider, + AI3DGenerateResult, + AI3DGenerateOptions, +} from './ai-3d-provider.interface'; import { TencentCloudSigner } from '../utils/tencent-cloud-sign'; import { ZipHandler } from '../utils/zip-handler'; @@ -43,6 +47,7 @@ export class HunyuanAI3DProvider implements AI3DProvider { async submitTask( inputType: 'text' | 'image', inputContent: string, + options?: AI3DGenerateOptions, ): Promise { try { // 构造请求参数 @@ -51,7 +56,19 @@ export class HunyuanAI3DProvider implements AI3DProvider { if (inputType === 'text') { // 文生3D:使用 Prompt payload.Prompt = inputContent; - this.logger.log(`提交文生3D任务: ${inputContent.substring(0, 50)}...`); + + // 文生3D支持额外参数 + if (options?.generateType) { + payload.GenerateType = options.generateType; + } + if (options?.faceCount) { + payload.FaceCount = options.faceCount; + } + + this.logger.log( + `提交文生3D任务: ${inputContent.substring(0, 50)}... ` + + `[类型: ${options?.generateType || 'Normal'}, 面数: ${options?.faceCount || 500000}]`, + ); } else { // 图生3D:使用 ImageUrl 或 ImageBase64 if ( @@ -59,14 +76,20 @@ export class HunyuanAI3DProvider implements AI3DProvider { inputContent.startsWith('https://') ) { payload.ImageUrl = inputContent; - this.logger.log(`提交图生3D任务 (URL): ${inputContent}`); } else { // 假设是 Base64 编码的图片 payload.ImageBase64 = inputContent; - this.logger.log( - `提交图生3D任务 (Base64): ${inputContent.substring(0, 30)}...`, - ); } + + // 图生3D也支持模型类型 + if (options?.generateType) { + payload.GenerateType = options.generateType; + } + + this.logger.log( + `提交图生3D任务: ${inputContent.substring(0, 50)}... ` + + `[类型: ${options?.generateType || 'Normal'}]`, + ); } // 生成签名和请求头 diff --git a/frontend/src/api/ai-3d.ts b/frontend/src/api/ai-3d.ts index 5c072ce..2f16c14 100644 --- a/frontend/src/api/ai-3d.ts +++ b/frontend/src/api/ai-3d.ts @@ -40,12 +40,21 @@ export interface AI3DTask { completeTime?: string; } +/** + * 模型生成类型 + */ +export type AI3DGenerateType = "Normal" | "LowPoly" | "Geometry" | "Sketch"; + /** * 创建任务参数 */ export interface CreateAI3DTaskParams { inputType: AI3DInputType; inputContent: string; + /** 模型生成类型:Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */ + generateType?: AI3DGenerateType; + /** 模型面数:10000-1500000,默认500000 */ + faceCount?: number; } /** diff --git a/frontend/src/views/workbench/ai-3d/Generate.vue b/frontend/src/views/workbench/ai-3d/Generate.vue index cd573f7..d7a35d2 100644 --- a/frontend/src/views/workbench/ai-3d/Generate.vue +++ b/frontend/src/views/workbench/ai-3d/Generate.vue @@ -567,10 +567,15 @@ $gradient-card: linear-gradient( // ========================================== .model-grid { display: grid; - grid-template-columns: repeat(auto-fit, 300px); + grid-template-columns: repeat(2, 300px); gap: 24px; margin-bottom: 40px; justify-content: center; + + // 只有1个卡片时居中 + &:has(.model-card:only-child) { + grid-template-columns: 300px; + } } // ========================================== diff --git a/frontend/src/views/workbench/ai-3d/Index.vue b/frontend/src/views/workbench/ai-3d/Index.vue index 7aed67b..f5c10a4 100644 --- a/frontend/src/views/workbench/ai-3d/Index.vue +++ b/frontend/src/views/workbench/ai-3d/Index.vue @@ -43,14 +43,17 @@
用文字描述你的想法,AI 将为你生成精美的 3D 模型
- +
+ + {{ textContent.length }}/1024 +
+
灵感推荐 @@ -67,6 +70,31 @@
+ + +
+
+
+ 模型类型 + + 带纹理 + 低多边形 + 白模 + 草图 + +
+
+ 模型面数 + + 5万面 + 10万面 + 30万面 + 50万面 + 100万面 + +
+
+
@@ -79,18 +107,40 @@ 上传参考图片,AI 将智能识别并生成 3D 模型 -

- -

-

点击或拖拽图片到此处

-

支持 JPG、PNG 格式,最大 10MB

+ +
+ + +
+
+
+ 模型类型 + + 带纹理 + 低多边形 + 白模 + 草图 + +
+
+
@@ -309,7 +359,6 @@ import { ArrowLeftOutlined, ClockCircleOutlined, } from "@ant-design/icons-vue" -import type { UploadFile } from "ant-design-vue" import { createAI3DTask, getAI3DTasks, @@ -358,11 +407,14 @@ const samplePrompts = [ // 状态 const inputType = ref<"text" | "image">("text") const textContent = ref("") -const imageFileList = ref() const imageUrl = ref("") const generating = ref(false) const currentSampleIndex = ref(0) +// 模型设置 +const generateType = ref<"Normal" | "LowPoly" | "Geometry" | "Sketch">("Normal") +const faceCount = ref(500000) + // 历史记录 const historyList = ref([]) const historyLoading = ref(false) @@ -448,12 +500,18 @@ const handleBeforeUpload = async (file: File) => { // 上传图片 try { - const result = await uploadFile(file) - imageUrl.value = result.url - message.success("图片上传成功") + const result: any = await uploadFile(file) + // 兼容不同的响应格式 + const url = result.data?.url || result.url + if (url) { + imageUrl.value = url + message.success("图片上传成功") + } else { + message.error("上传失败:无法获取图片地址") + } } catch (error) { + console.error("上传失败:", error) message.error("图片上传失败") - return false } return false // 阻止默认上传行为 @@ -468,16 +526,24 @@ const handleGenerate = async () => { const content = inputType.value === "text" ? textContent.value.trim() : imageUrl.value - const task = await createAI3DTask({ + // 构建请求参数 + const params: any = { inputType: inputType.value, inputContent: content, - }) + generateType: generateType.value, + } + + // 文生3D时添加模型面数参数 + if (inputType.value === "text") { + params.faceCount = faceCount.value + } + + const task = await createAI3DTask(params) // 清空输入 if (inputType.value === "text") { textContent.value = "" } else { - imageFileList.value = [] imageUrl.value = "" } @@ -943,6 +1009,19 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%); line-height: 1.6; } +.textarea-wrapper { + position: relative; + + .char-count { + position: absolute; + bottom: 8px; + right: 12px; + font-size: 12px; + color: $text-muted; + pointer-events: none; + } +} + .text-input { background: rgba($surface-light, 0.6) !important; border: 2px solid rgba($primary, 0.15) !important; @@ -950,6 +1029,7 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%); color: $text; resize: none; transition: all 0.3s; + padding-bottom: 28px !important; &::placeholder { color: rgba($text-muted, 0.5); @@ -964,14 +1044,67 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%); box-shadow: 0 0 0 4px rgba($primary, 0.1) !important; background: rgba($surface-light, 0.8) !important; } +} - :deep(.ant-input-data-count) { - color: $text-muted; +// 模型设置 +.model-settings { + margin-top: 16px; + padding: 16px; + background: rgba($primary, 0.05); + border: 1px solid rgba($primary, 0.15); + border-radius: 12px; + + .setting-row { + display: flex; + gap: 12px; + } + + .setting-item { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + + &.full-width { + flex: none; + width: 100%; + } + } + + .setting-label { + font-size: 12px; + font-weight: 500; + color: $text-secondary; + } + + .setting-select { + width: 100%; + + :deep(.ant-select-selector) { + background: rgba($surface, 0.8) !important; + border: 1px solid rgba($primary, 0.2) !important; + border-radius: 8px !important; + height: 36px !important; + + .ant-select-selection-item { + line-height: 34px !important; + font-size: 13px; + } + } + + &:hover :deep(.ant-select-selector) { + border-color: rgba($primary, 0.4) !important; + } + + &.ant-select-focused :deep(.ant-select-selector) { + border-color: $primary !important; + box-shadow: 0 0 0 2px rgba($primary, 0.1) !important; + } } } .sample-section { - margin-top: 20px; + margin-top: 16px; } .sample-header { @@ -1028,28 +1161,51 @@ $gradient-secondary: linear-gradient(135deg, $accent 0%, $primary 100%); border: 2px dashed rgba($primary, 0.3) !important; border-radius: 12px; transition: all 0.3s; + overflow: hidden; &:hover { border-color: $primary-light !important; background: rgba($surface-light, 0.8) !important; } + } - .upload-icon { - color: $primary-light; - font-size: 56px; - margin-bottom: 12px; - } + :deep(.ant-upload) { + padding: 24px !important; + } - .upload-text { - color: $text; - font-size: 15px; - margin-bottom: 8px; - } + :deep(.upload-icon) { + color: $primary-light; + font-size: 56px; + margin-bottom: 12px; + } - .upload-hint { - color: $text-muted; - font-size: 12px; - } + :deep(.upload-text) { + color: $text; + font-size: 15px; + margin-bottom: 8px; + } + + :deep(.upload-hint) { + color: $text-muted; + font-size: 12px; + } + + :deep(.upload-preview-image) { + max-width: 100%; + max-height: 180px; + object-fit: contain; + border-radius: 8px; + } + + :deep(.upload-change-hint) { + margin-top: 12px; + font-size: 13px; + color: $text-muted; + transition: color 0.3s; + } + + :deep(.ant-upload-drag:hover .upload-change-hint) { + color: $primary-light; } }