feat: AI生成工程大样图(整合autocad项目大样图提示词资产, blueprint模式2D图纸转黑白CAD大样图, 修复loadTemplate不回写templateId)

This commit is contained in:
HHY
2026-10-06 07:18:19 -07:00
parent a00c6c1e7b
commit 94f9a26cdc
7 changed files with 91 additions and 16 deletions
+1
View File
@@ -1,5 +1,6 @@
# CHANGELOG # CHANGELOG
- [2026-10-06 07:17] FEAT: v2.44 AI 生成工程大样图(整合自 D:\Codeutocad 项目草图标准化能力)。后端 /api/ai-render 新增 blueprint 模式: 2D 图纸截图(sketch_b64, WindoorCanvas 新增 exportImage() 画布导出)为底图 images/edits 重绘黑白 CAD 工程大样图; 大样图提示词移植 autocad 项目风格迁移资产(正投影立面/细黑线框/玻璃淡灰填充/尺寸线/中文工程标注/标题栏+不编造尺寸/仅供审图与报价附件约束)并叠加 describe_window 窗型分格特征增强保真; /prompt 端点支持 blueprint 预览; 前端 AI 炫图页新增「工程大样图」Tab(样板选择+2D 图纸底图+一键生成+结果下载); 真实生成实测 59~68s: 样板704 → 黑白大样图(分格 F1/A1/F2 与原图一致, 开启扇对角线+把手保留, 尺寸标注 600/1200/400/1200 与几何一致, 中文引出线标注+说明+标题栏图名/规格/比例/日期); 修复 AIRenderView.loadTemplate 不回写 templateId(程序化自动选样板后 template_id=undefined 报未找到图纸数据, 手动下拉路径 v-model 自动更新故从未暴露); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/ai_render.py, frontend/src/views/AIRenderView.vue, frontend/src/components/WindoorCanvas.vue, doc/API.md)
- [2026-10-06 05:57] FEAT: v2.43 部位级材料选择——对应部位使用对应材料。估算器 estimate() 增加 material_overrides(部位级覆盖: profile 按 _profile_role 名称归位 frame/mullion/sash/pressline, 命中行替换名称/单价/米重; glass/hardware 整类替换)+overrides 回显; 新增 GET /api/costbook/series-materials(聚合全部系列用料按部位分组去重, 供跨系列混搭: frame 3 种/mullion 3/sash 2/pressline 6/glass 5/hardware 1); offer-price 新增 materials 参数透传; QuoteDialog 部位选材面板(外框型材/中梃型材/扇框型材/玻璃配置/五金套件 五下拉, 选项带价格与系列来源, 改选即重算 offer-price 实时刷新系列成本); 端到端实测: 样板704+系列75, 外框改选 95 系列 33框(25.88元/kg) → 系列成本 1251.10→1303.57 实时重算(手算吻合), 报价总额独立对比; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/quote.py, src/huamenchuang/api/costbook.py, frontend/src/components/QuoteDialog.vue, doc/API.md) - [2026-10-06 05:57] FEAT: v2.43 部位级材料选择——对应部位使用对应材料。估算器 estimate() 增加 material_overrides(部位级覆盖: profile 按 _profile_role 名称归位 frame/mullion/sash/pressline, 命中行替换名称/单价/米重; glass/hardware 整类替换)+overrides 回显; 新增 GET /api/costbook/series-materials(聚合全部系列用料按部位分组去重, 供跨系列混搭: frame 3 种/mullion 3/sash 2/pressline 6/glass 5/hardware 1); offer-price 新增 materials 参数透传; QuoteDialog 部位选材面板(外框型材/中梃型材/扇框型材/玻璃配置/五金套件 五下拉, 选项带价格与系列来源, 改选即重算 offer-price 实时刷新系列成本); 端到端实测: 样板704+系列75, 外框改选 95 系列 33框(25.88元/kg) → 系列成本 1251.10→1303.57 实时重算(手算吻合), 报价总额独立对比; 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/quote.py, src/huamenchuang/api/costbook.py, frontend/src/components/QuoteDialog.vue, doc/API.md)
- [2026-10-06 08:05] FEAT: v2.42 样板库整合材料系列选择——商家选材闭环。样板库预览对话框(编辑/非编辑态)新增「材料系列」选择器(GET /api/costbook/series: 75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, 来自鲁铭成本核算 Excel); 选择后算料报价携带 series_id → /api/offer-price 响应附加 series(系列名/壁厚)+series_estimate(按图纸实际宽高的系列八分区成本估算: 总成本/每平/型材/玻璃/五金/人工/margin_ladder 毛利阶梯); QuoteDialog 新增「材料系列成本参考」面板(统计卡+分区明细+建议售价50%毛利), 与报价总额并列对比辅助商家决策; 端到端实测: 样板704(1800x1600)+系列75 → 报价761.08元, 系列成本参考1251.1元/樘(每平434.41, 50%毛利建议1876.65); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/quote.py, frontend/src/components/QuoteDialog.vue, frontend/src/views/TemplatesView.vue, doc/API.md) - [2026-10-06 08:05] FEAT: v2.42 样板库整合材料系列选择——商家选材闭环。样板库预览对话框(编辑/非编辑态)新增「材料系列」选择器(GET /api/costbook/series: 75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, 来自鲁铭成本核算 Excel); 选择后算料报价携带 series_id → /api/offer-price 响应附加 series(系列名/壁厚)+series_estimate(按图纸实际宽高的系列八分区成本估算: 总成本/每平/型材/玻璃/五金/人工/margin_ladder 毛利阶梯); QuoteDialog 新增「材料系列成本参考」面板(统计卡+分区明细+建议售价50%毛利), 与报价总额并列对比辅助商家决策; 端到端实测: 样板704(1800x1600)+系列75 → 报价761.08元, 系列成本参考1251.1元/樘(每平434.41, 50%毛利建议1876.65); 回归: 冒烟 60/60+24 页巡检 0 异常 (Files: src/huamenchuang/api/quote.py, frontend/src/components/QuoteDialog.vue, frontend/src/views/TemplatesView.vue, doc/API.md)
- [2026-10-05 07:52] FIX: v2.41 门窗渲染语义区分(对齐官方图纸)。对照官方参考图(windoor-images/31337/31343/31637)逐项核对门的视觉语义, 三渲染器同步: ①门把手贯穿长条(约55%扇高, 官方门统一长条贯穿中缝侧) vs 窗短把手(20%) ②门上下锁点小方块(RenderCanvas 此前缺失, 补齐; WindoorCanvas 已有) ③裙板色=型材同色系(RenderCanvas 取 shape.cm.g 暗化 62%, 解析失败退中性深灰; WindoorCanvas 图纸深灰+竖纹肌理+浅色编号; 3D 0x606369)——此前裙板米色与型材脱节; 门窗开向符号(V形虚线顶点指铰链侧)经官方图核对为同一语义无需区分; 视觉验收: 31343/31337 门(长把手+锁点+型材色裙板)与 704 窗(短把手)对比官方图一致; 回归: 24 页巡检 0 异常+冒烟 60/60 (Files: frontend/src/components/RenderCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/components/WindoorThreed.vue) - [2026-10-05 07:52] FIX: v2.41 门窗渲染语义区分(对齐官方图纸)。对照官方参考图(windoor-images/31337/31343/31637)逐项核对门的视觉语义, 三渲染器同步: ①门把手贯穿长条(约55%扇高, 官方门统一长条贯穿中缝侧) vs 窗短把手(20%) ②门上下锁点小方块(RenderCanvas 此前缺失, 补齐; WindoorCanvas 已有) ③裙板色=型材同色系(RenderCanvas 取 shape.cm.g 暗化 62%, 解析失败退中性深灰; WindoorCanvas 图纸深灰+竖纹肌理+浅色编号; 3D 0x606369)——此前裙板米色与型材脱节; 门窗开向符号(V形虚线顶点指铰链侧)经官方图核对为同一语义无需区分; 视觉验收: 31343/31337 门(长把手+锁点+型材色裙板)与 704 窗(短把手)对比官方图一致; 回归: 24 页巡检 0 异常+冒烟 60/60 (Files: frontend/src/components/RenderCanvas.vue, frontend/src/components/WindoorCanvas.vue, frontend/src/components/WindoorThreed.vue)
+3 -1
View File
@@ -459,6 +459,8 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
### 12.22 照片式 AI 炫图(v2.20 升级,P0-照片炫图) ### 12.22 照片式 AI 炫图(v2.20 升级,P0-照片炫图)
> **v2.44 新增 `blueprint` 模式(工程大样图,整合自 autocad 项目草图标准化资产)**:`{mode:"blueprint", template_id|drawing_data, sketch_b64(2D 图纸截图)}` → AI 重绘为黑白 CAD 工程大样图(正投影立面/细黑线框/玻璃淡灰填充/尺寸线/中文工程标注/标题栏;严格保留分格与开启扇位置,不编造尺寸),可作报价与合同附件。提示词预览 `/api/ai-render/prompt` 同样支持 `mode:"blueprint"`。
对齐目标站 `renderV2` 炫图 7 分类:**渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯**。 对齐目标站 `renderV2` 炫图 7 分类:**渲图 / 安装合成 / 门窗擦除 / 室内装修 / 别墅 / 去围栏 / 变毛坯**。
纯前端像素合成(`components/photoCompose.ts`,无需服务端 AI): 纯前端像素合成(`components/photoCompose.ts`,无需服务端 AI):
@@ -476,7 +478,7 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000
| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | | 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 |
|---|---|---|---|---| |---|---|---|---|---|
| 提示词预览 | POST | `/api/ai-render/prompt` | `{template_id\|drawing_data, style, time, scene}` | 返回 `{features(窗型特征), prompt(优化后完整提示词), size}` | | 提示词预览 | POST | `/api/ai-render/prompt` | `{template_id\|drawing_data, style, time, scene}` | 返回 `{features(窗型特征), prompt(优化后完整提示词), size}` |
| **云端生成** | POST | `/api/ai-render/generate` | `{template_id\|drawing_data, mode: quick\|photo, style, time, scene, photo_b64?}` | quick→`images/generations`;photo→`images/edits`(用户照片为参考图,AI 将窗型合成进照片窗洞);返回 `{image_b64, prompt, size, features}`;耗时 30~90s(前端 timeout 200s) | | **云端生成** | POST | `/api/ai-render/generate` | `{template_id\|drawing_data, mode: quick\|photo\|blueprint, style, time, scene, photo_b64?, sketch_b64?}` | quick→`images/generations`;photo→`images/edits`(用户照片为参考图,AI 将窗型合成进照片窗洞);**blueprint(v2.44 工程大样图,整合自 autocad 项目)→`images/edits` 以 2D 图纸截图为底图重绘黑白 CAD 大样图**(正投影/尺寸线/中文工程标注/标题栏,严格保留分格与开启扇,不编造尺寸,可作报价与合同附件);返回 `{image_b64, prompt, size, features}`;耗时 30~90s(前端 timeout 200s) |
- 未配置 Key 时接口返回 code=0,前端降级本地程序化渲染("本地 4 方案"按钮) - 未配置 Key 时接口返回 code=0,前端降级本地程序化渲染("本地 4 方案"按钮)
- 前端:AI 炫图页两个模式(照片合成/风格速览)主按钮均走云端生成,结果大图展示 + 提示词折叠面板可查看 + PNG 下载;照片模式参考图自动压缩 ≤1536px JPEG - 前端:AI 炫图页两个模式(照片合成/风格速览)主按钮均走云端生成,结果大图展示 + 提示词折叠面板可查看 + PNG 下载;照片模式参考图自动压缩 ≤1536px JPEG
+8 -1
View File
@@ -1008,6 +1008,13 @@ function exportDrawing(): string | null {
return dd ? JSON.stringify(dd) : null return dd ? JSON.stringify(dd) : null
} }
/** 画布截图(dataURL):供 AI 大样图等以 2D 图纸为底图的场景。 */
function exportImage(): string | null {
const cv = cvRef.value
if (!cv || !cv.width) return null
try { return cv.toDataURL('image/png') } catch { return null }
}
// ---- 装饰格条设计(P0-格条设计器) ---- // ---- 装饰格条设计(P0-格条设计器) ----
const DEC_COLORS = [ const DEC_COLORS = [
{ label: '黑', v: '#2b2b2b' }, { label: '白', v: '#ffffff' }, { label: '灰', v: '#9aa0a6' }, { label: '黑', v: '#2b2b2b' }, { label: '白', v: '#ffffff' }, { label: '灰', v: '#9aa0a6' },
@@ -1120,7 +1127,7 @@ function removeAssign() {
emit('change') emit('change')
ElMessage.success('已清除该格指定') ElMessage.success('已清除该格指定')
} }
defineExpose({ exportDrawing }) defineExpose({ exportDrawing, exportImage })
// ============ 视图控制 ============ // ============ 视图控制 ============
+42 -9
View File
@@ -6,6 +6,7 @@
<el-radio-group v-model="tab" size="small"> <el-radio-group v-model="tab" size="small">
<el-radio-button value="photo">照片合成</el-radio-button> <el-radio-button value="photo">照片合成</el-radio-button>
<el-radio-button value="quick">风格速览</el-radio-button> <el-radio-button value="quick">风格速览</el-radio-button>
<el-radio-button value="blueprint">工程大样图</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
</template> </template>
@@ -53,7 +54,7 @@
</template> </template>
<!-- ============ 风格速览(原程序化模式) ============ --> <!-- ============ 风格速览(原程序化模式) ============ -->
<template v-else> <template v-else-if="tab === 'quick'">
<div class="controls"> <div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate"> <el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" /> <el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
@@ -83,6 +84,25 @@
<el-empty v-if="!drawing" description="请先选择样板" /> <el-empty v-if="!drawing" description="请先选择样板" />
</template> </template>
<!-- ============ 工程大样图(2D 图纸 → 黑白 CAD 大样图, 整合自 autocad 项目草图标准化能力) ============ -->
<template v-else-if="tab === 'blueprint'">
<div class="controls">
<el-select v-model="templateId" filterable placeholder="选择样板" style="width:220px" @change="loadTemplate">
<el-option v-for="t in templates" :key="t.id" :label="t.name || `样板#${t.id}`" :value="t.id" />
</el-select>
<span class="tip">以 2D 图纸为底图,AI 重绘为黑白 CAD 工程大样图(含尺寸线/标题栏,可作报价与合同附件)</span>
<div style="flex:1" />
<el-button type="primary" :disabled="!drawing || generating" :loading="generating" @click="generateBlueprint">
{{ generating ? 'AI 绘制中…' : '✦ AI 生成大样图' }}</el-button>
</div>
<el-progress v-if="generating" :percentage="progress" :stroke-width="10" style="margin: 10px 0"
:format="() => stageText" />
<div v-if="drawing" class="bp-stage">
<WindoorCanvas ref="bpCanvasRef" :drawing-data="drawing" :editable="false" />
</div>
<el-empty v-else description="请先选择样板" />
</template>
<div v-if="variants.length" class="gallery-foot"> <div v-if="variants.length" class="gallery-foot">
<span class="tip">本地 4 方案(程序化渲染降级模式)· 点击图标下载</span> <span class="tip">本地 4 方案(程序化渲染降级模式)· 点击图标下载</span>
</div> </div>
@@ -109,9 +129,11 @@ import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { api } from '../api' import { api } from '../api'
import AIRenderCanvas from '../components/AIRenderCanvas.vue' import AIRenderCanvas from '../components/AIRenderCanvas.vue'
import WindoorCanvas from '../components/WindoorCanvas.vue'
import { PHOTO_SCENES, type PhotoScene } from '../components/photoCompose' import { PHOTO_SCENES, type PhotoScene } from '../components/photoCompose'
const tab = ref<'photo' | 'quick'>('photo') const tab = ref<'photo' | 'quick' | 'blueprint'>('photo')
const bpCanvasRef = ref()
const templates = ref<any[]>([]) const templates = ref<any[]>([])
const templateId = ref<number | undefined>() const templateId = ref<number | undefined>()
const drawing = ref('') const drawing = ref('')
@@ -142,9 +164,10 @@ let dragStart: { x: number; y: number } | null = null
async function loadTemplate(tid: number | undefined) { async function loadTemplate(tid: number | undefined) {
drawing.value = '' drawing.value = ''
variants.value = [] variants.value = []
if (!tid) return if (!tid) { templateId.value = undefined; return }
const t = await api<any>('get', `/api/windoor/templates/${tid}`) const t = await api<any>('get', `/api/windoor/templates/${tid}`)
drawing.value = t.drawing_data drawing.value = t.drawing_data
templateId.value = tid
} }
// ---- 照片上传与框选 ---- // ---- 照片上传与框选 ----
@@ -229,14 +252,14 @@ const aiImage = ref('')
const aiPrompt = ref('') const aiPrompt = ref('')
const aiFeatures = ref('') const aiFeatures = ref('')
async function generateCloud(mode: 'quick' | 'photo') { async function generateCloud(mode: 'quick' | 'photo' | 'blueprint', sketch_b64?: string) {
if (!drawing.value) return if (!drawing.value) return
if (mode === 'photo' && (!photo.value || !rect.value)) return if (mode === 'photo' && (!photo.value || !rect.value)) return
generating.value = true generating.value = true
aiImage.value = '' aiImage.value = ''
variants.value = [] variants.value = []
progress.value = 8 progress.value = 8
stageText.value = mode === 'photo' ? '上传照片与窗型特征' : '解析窗型结构' stageText.value = mode === 'photo' ? '上传照片与窗型特征' : mode === 'blueprint' ? '上传 2D 图纸与分格特征' : '解析窗型结构'
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
progress.value = Math.min(92, progress.value + 2) progress.value = Math.min(92, progress.value + 2)
if (progress.value > 40) stageText.value = 'AI 绘制中(约 30~90 秒, 请勿关闭页面)' if (progress.value > 40) stageText.value = 'AI 绘制中(约 30~90 秒, 请勿关闭页面)'
@@ -254,13 +277,13 @@ async function generateCloud(mode: 'quick' | 'photo') {
const d = await api<any>('post', '/api/ai-render/generate', { const d = await api<any>('post', '/api/ai-render/generate', {
template_id: templateId.value, mode, template_id: templateId.value, mode,
style: form.ai_style, time: form.ai_time, style: form.ai_style, time: form.ai_time,
scene: mode === 'photo' ? scene.value : form.scene, photo_b64, rect: rectPayload, scene: mode === 'photo' ? scene.value : form.scene, photo_b64, rect: rectPayload, sketch_b64,
}, { timeout: 200000 }) }, { timeout: 200000 })
aiImage.value = `data:image/png;base64,${d.image_b64}` aiImage.value = `data:image/png;base64,${d.image_b64}`
aiPrompt.value = d.prompt aiPrompt.value = d.prompt
aiFeatures.value = d.features?.summary ?? '' aiFeatures.value = d.features?.summary ?? ''
progress.value = 100 progress.value = 100
ElMessage.success('AI 炫图已生成') ElMessage.success(mode === 'blueprint' ? 'AI 大样图已生成' : 'AI 炫图已生成')
} catch { /* 拦截器已提示 */ } finally { } catch { /* 拦截器已提示 */ } finally {
window.clearInterval(timer) window.clearInterval(timer)
generating.value = false generating.value = false
@@ -268,6 +291,14 @@ async function generateCloud(mode: 'quick' | 'photo') {
} }
} }
/** 工程大样图: 以 2D 图纸画布截图为底图, AI 重绘为黑白 CAD 大样图 */
async function generateBlueprint() {
await nextTick()
const dataUrl = bpCanvasRef.value?.exportImage?.()
if (!dataUrl) { ElMessage.warning('图纸截图失败,请稍后重试'); return }
await generateCloud('blueprint', dataUrl.split(',')[1])
}
/** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */ /** 照片模式参考图: 全图压至 ≤1536px JPEG base64 */
function photoB64(): string { function photoB64(): string {
const img = photo.value! const img = photo.value!
@@ -344,6 +375,8 @@ onMounted(async () => {
templates.value = (await api<any>('get', '/api/windoor/templates?page=1&page_size=254')).data templates.value = (await api<any>('get', '/api/windoor/templates?page=1&page_size=254')).data
Object.assign(form, { ai_style: form.ai_style, ai_time: form.ai_time, scene: form.scene }, Object.assign(form, { ai_style: form.ai_style, ai_time: form.ai_time, scene: form.scene },
await api<any>('get', '/api/rendering-info')) await api<any>('get', '/api/rendering-info'))
// 自动选中首个样板, 三种模式即刻有底图
if (templates.value.length && !templateId.value) await loadTemplate(templates.value[0].id)
}) })
</script> </script>
@@ -358,11 +391,11 @@ onMounted(async () => {
.drop-hint:hover { border-color: #5b3fd4; background: #faf8ff; } .drop-hint:hover { border-color: #5b3fd4; background: #faf8ff; }
.drop-hint p { margin: 10px 0 0; } .drop-hint p { margin: 10px 0 0; }
.drop-hint .sub { font-size: 12px; } .drop-hint .sub { font-size: 12px; }
.photo-stage { position: relative; display: flex; justify-content: center; } .photo-stage { position: relative; display: flex; justify-content: center; }.photo-cv { max-width: 100%; border-radius: 10px; cursor: crosshair; box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.photo-cv { max-width: 100%; border-radius: 10px; cursor: crosshair; box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.stage-tip { position: absolute; left: 12px; top: 12px; color: #fff; font-size: 12px; .stage-tip { position: absolute; left: 12px; top: 12px; color: #fff; font-size: 12px;
background: rgba(0,0,0,0.5); padding: 4px 10px; border-radius: 10px; pointer-events: none; } background: rgba(0,0,0,0.5); padding: 4px 10px; border-radius: 10px; pointer-events: none; }
.preview { height: 58vh; } .preview { height: 58vh; }
.bp-stage { height: 58vh; border: 1px solid #e4e7ed; border-radius: 6px; overflow: hidden; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; } .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
.gallery-foot { margin-top: 8px; } .gallery-foot { margin-top: 8px; }
.ai-result { margin-top: 14px; text-align: center; } .ai-result { margin-top: 14px; text-align: center; }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+37 -5
View File
@@ -21,13 +21,14 @@ router = APIRouter(prefix="/ai-render", tags=["AI 炫图"])
class AiRenderBody(BaseModel): class AiRenderBody(BaseModel):
template_id: int | None = None template_id: int | None = None
drawing_data: str | dict | None = None drawing_data: str | dict | None = None
mode: str = Field(default="quick", pattern="^(quick|photo)$") mode: str = Field(default="quick", pattern="^(quick|photo|blueprint)$")
style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$") style: str = Field(default="modern", pattern="^(modern|chinese|european|nordic)$")
time: str = Field(default="day", pattern="^(day|dusk|night)$") time: str = Field(default="day", pattern="^(day|dusk|night)$")
# quick: indoor/outdoor/studio; photo: render/install/erase/indoor/villa/fence/blank/sunroom-* # quick: indoor/outdoor/studio; photo: render/install/erase/indoor/villa/fence/blank/sunroom-*
scene: str = "indoor" scene: str = "indoor"
photo_b64: str | None = Field(default=None, description="照片模式参考图(base64, 不带 dataURL 前缀)") photo_b64: str | None = Field(default=None, description="照片模式参考图(base64, 不带 dataURL 前缀)")
rect: dict | None = Field(default=None, description="照片模式框选窗洞 {x,y,w,h}(原始照片像素坐标)") rect: dict | None = Field(default=None, description="照片模式框选窗洞 {x,y,w,h}(原始照片像素坐标)")
sketch_b64: str | None = Field(default=None, description="大样图模式: 2D 图纸截图(base64, 不带 dataURL 前缀)")
async def _resolve_drawing(db, body: AiRenderBody) -> str | dict | None: async def _resolve_drawing(db, body: AiRenderBody) -> str | dict | None:
@@ -50,16 +51,34 @@ async def preview_prompt(body: AiRenderBody, user: CurrentUser, db: DbSession):
return fail("未找到图纸数据") return fail("未找到图纸数据")
try: try:
info = describe_window(drawing) info = describe_window(drawing)
prompt = build_prompt(drawing, body.style, body.time, body.scene) prompt = (build_blueprint_prompt(drawing) if body.mode == "blueprint"
else build_prompt(drawing, body.style, body.time, body.scene))
except Exception: except Exception:
return fail("图纸数据解析失败") return fail("图纸数据解析失败")
return ok(data={"features": info, "prompt": prompt, return ok(data={"features": info, "prompt": prompt,
"size": pick_size(info["width_mm"], info["height_mm"])}) "size": pick_size(info["width_mm"], info["height_mm"])})
def build_blueprint_prompt(drawing: str | dict) -> str:
"""工程大样图提示词:2D 图纸 → 黑白 CAD 大样图风格(移植自 autocad 项目草图标准化资产,
叠加 drawing_data 结构化特征增强分格保真)。"""
try:
info = describe_window(drawing)
feat = f"窗型实际分格: {info['summary']}; 外框尺寸 {info['width_mm']}×{info['height_mm']} mm。"
except Exception:
feat = ""
return f"""Use case: style-transfer
Asset type: 门窗工程审图参考图
Primary request: 将输入的门窗 2D 图纸重绘为清晰、整洁的工程 CAD 大样图风格预览。
Input images: 第一张是原始 2D 图纸(彩色渲染稿),仅作为几何与分格的几何来源。
Style/medium: 黑白二维门窗工程大样图、正投影立面、细黑线框、玻璃淡灰填充、外部尺寸线与分格尺寸标注、中文工程标注、右下角标题栏(图名/比例/日期)。
Constraints: {feat} 严格保留原图的可辨识总体构图、窗扇/玻璃分格数量与比例、开启扇方向线与把手位置;不要增删或合并分格;不要编造缺失尺寸,标注尺寸须与原图几何一致;输出仅供人工审图与报价附件,不作为 CAD 几何或生产数据来源;无水印、无装饰性场景、无彩色。
Avoid: 三维渲染、透视效果、写实照片、彩色渲染、虚构品牌、模糊文字。"""
@router.post("/generate") @router.post("/generate")
async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession): async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
"""云端 AI 生图(gpt-image):quick=生成 / photo=参考图编辑。""" """云端 AI 生图(gpt-image):quick=生成 / photo=参考图编辑 / blueprint=2D图纸转工程大样图。"""
if not settings.ai_api_key: if not settings.ai_api_key:
return fail("AI 生图服务未配置(HMC_AI_API_KEY)") return fail("AI 生图服务未配置(HMC_AI_API_KEY)")
drawing = await _resolve_drawing(db, body) drawing = await _resolve_drawing(db, body)
@@ -67,6 +86,8 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
return fail("未找到图纸数据") return fail("未找到图纸数据")
if body.mode == "photo" and not body.photo_b64: if body.mode == "photo" and not body.photo_b64:
return fail("照片模式需要上传参考照片") return fail("照片模式需要上传参考照片")
if body.mode == "blueprint" and not body.sketch_b64:
return fail("大样图模式需要 2D 图纸截图")
try: try:
info = describe_window(drawing) info = describe_window(drawing)
# 照片模式: 把框选洞口位置转成提示词方位描述, 引导 AI 装窗位置 # 照片模式: 把框选洞口位置转成提示词方位描述, 引导 AI 装窗位置
@@ -88,7 +109,10 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
f"replacing whatever old window or opening is there.") f"replacing whatever old window or opening is there.")
except Exception: except Exception:
rect_hint = "" rect_hint = ""
prompt = build_prompt(drawing, body.style, body.time, body.scene, rect_hint=rect_hint) if body.mode == "blueprint":
prompt = build_blueprint_prompt(drawing)
else:
prompt = build_prompt(drawing, body.style, body.time, body.scene, rect_hint=rect_hint)
size = pick_size(info["width_mm"], info["height_mm"]) size = pick_size(info["width_mm"], info["height_mm"])
except Exception: except Exception:
return fail("图纸数据解析失败") return fail("图纸数据解析失败")
@@ -98,7 +122,15 @@ async def generate(body: AiRenderBody, user: CurrentUser, db: DbSession):
timeout = httpx.Timeout(settings.ai_timeout, connect=15) timeout = httpx.Timeout(settings.ai_timeout, connect=15)
try: try:
async with httpx.AsyncClient(timeout=timeout) as client: async with httpx.AsyncClient(timeout=timeout) as client:
if body.mode == "photo": if body.mode == "blueprint":
img_bytes = base64.b64decode(body.sketch_b64.split(",")[-1])
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
ext = "jpg" if mime == "image/jpeg" else "png"
resp = await client.post(
f"{base}/v1/images/edits", headers=headers,
data={"model": settings.ai_model, "prompt": prompt, "size": size, "n": "1"},
files={"image[]": (f"drawing.{ext}", img_bytes, mime)})
elif body.mode == "photo":
img_bytes = base64.b64decode(body.photo_b64.split(",")[-1]) img_bytes = base64.b64decode(body.photo_b64.split(",")[-1])
mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png" mime = "image/jpeg" if img_bytes[:2] == b"\xff\xd8" else "image/png"
ext = "jpg" if mime == "image/jpeg" else "png" ext = "jpg" if mime == "image/jpeg" else "png"