feat(ai-render): finalize model-locked image generation

This commit is contained in:
WuYongJie 2026-06-16 16:08:09 +08:00
parent e3fa838b39
commit 1c055d97c5
5 changed files with 210 additions and 56 deletions

View File

@ -51,6 +51,19 @@ const DEFAULT_NEGATIVE_PROMPT = 'deformed product, wrong structure, extra parts,
const LEGACY_SAMPLE_SPU_ID = 'spu-cj0001'
const LEGACY_SAMPLE_SKU_IDS = new Set(['sku-cj000101', 'sku-cj000102', 'sku-cj000103'])
const CURVE_DETAIL_ENHANCEMENT_PROMPT =
'Treat visible low-polygon CAD faceting as an export artifact, not as intended product design. Visually refine curved and circular edges so an intended round arc that appears as roughly 12 straight facets is rendered with a smoother high-resolution appearance closer to 36 facets. Preserve the exact product structure, overall dimensions, straight edges, flat panels, hard corners, holes, slots, seams, material grain direction, texture placement, and part boundaries.'
const CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT =
'changed product structure, changed dimensions, extra holes, missing holes, missing slots, distorted straight edges, melted corners, warped panels, altered material grain direction, changed part boundaries, blurry product'
function mergeNegativePrompt(...parts: Array<string | null | undefined>): string | null {
const normalized = parts
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part))
return normalized.length > 0 ? normalized.join(', ') : null
}
type StoredWorkbenchState = {
spus: AiRenderSpu[]
selectedSpuId: string | null
@ -360,6 +373,7 @@ function buildWhiteBackgroundPrompt(params: {
spu: AiRenderSpu | null
sku: AiRenderSku
userPrompt: string
aspectRatio: string
}): string {
const materialNames = uniqueNodeNames(
Object.values(params.sku.nodeMaterials ?? {}).map((material) => material.materialName),
@ -369,10 +383,13 @@ function buildWhiteBackgroundPrompt(params: {
.filter(Boolean)
.join(' / ')
const userPrompt = params.userPrompt.trim()
const aspectRatio = params.aspectRatio.trim()
return [
'Create a realistic studio product image from this 3D furniture product reference.',
'Keep the same product structure, proportions, camera angle, material, color, texture direction, hardware, seams, and details.',
aspectRatio ? `Final output must be exactly ${aspectRatio}.` : '',
CURVE_DETAIL_ENHANCEMENT_PROMPT,
'Remove the UI, viewport grid, shadows from the interface, and any unrelated background.',
'Use a clean pure white background, centered composition, soft product-photography lighting, and natural contact shadow only.',
productLabel ? `Product: ${productLabel}.` : '',
@ -388,6 +405,7 @@ function buildLockedScenePrompt(params: {
spu: AiRenderSpu | null
sku: AiRenderSku
userPrompt: string
aspectRatio: string
}): string {
const productLabel = [params.spu?.name, params.sku.name]
.map((item) => item?.trim())
@ -397,10 +415,13 @@ function buildLockedScenePrompt(params: {
Object.values(params.sku.nodeMaterials ?? {}).map((material) => material.materialName),
).slice(0, 8)
const scenePrompt = params.userPrompt.trim()
const aspectRatio = params.aspectRatio.trim()
return [
'Generate a realistic lifestyle scene for this furniture product while keeping the product locked to the reference image.',
'The product geometry must not be redesigned: keep the exact silhouette, proportions, chamfers, bevels, holes, seams, panel thickness, hardware, material color, grain direction, and visible structural details from the reference render.',
aspectRatio ? `Final output must be exactly ${aspectRatio}.` : '',
CURVE_DETAIL_ENHANCEMENT_PROMPT,
'Use the user request only for the surrounding scene, lighting, camera mood, and background styling. Do not let the prompt alter the product body.',
productLabel ? `Product: ${productLabel}.` : '',
materialNames.length > 0 ? `Visible product materials: ${materialNames.join(', ')}.` : '',
@ -1017,28 +1038,43 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
}
}, [spus, updateSku])
const createLocalRecord = useCallback((params: {
const appendRenderedRecords = useCallback((params: {
mode: LocalRenderRecord['mode']
imageUrl: string
images: Array<{ originalUrl?: string | null; thumbnailUrl?: string | null }>
prompt?: string
status?: LocalRenderRecord['status']
}) => {
if (!selectedSku) return
setRecords((current) => [
{
id: createId('render'),
skuName: selectedSku.name || '未命名 SKU',
mode: params.mode,
imageUrl: params.imageUrl,
prompt: params.prompt ?? prompt,
status: params.status ?? 'SUCCEEDED',
createdAt: new Date().toISOString(),
},
...current,
].slice(0, 18))
if (!selectedSku) return 0
const createdAt = new Date().toISOString()
const nextRecords = params.images
.map((image) => {
const imageUrl =
typeof image.originalUrl === 'string' && image.originalUrl.trim()
? image.originalUrl.trim()
: typeof image.thumbnailUrl === 'string' && image.thumbnailUrl.trim()
? image.thumbnailUrl.trim()
: ''
if (!imageUrl) return null
return {
id: createId('render'),
skuName: selectedSku.name || '未命名 SKU',
mode: params.mode,
imageUrl,
prompt: params.prompt ?? prompt,
status: params.status ?? 'SUCCEEDED',
createdAt,
}
})
.filter((record): record is LocalRenderRecord => record !== null)
if (nextRecords.length === 0) return 0
setRecords((current) => [...nextRecords, ...current].slice(0, 18))
return nextRecords.length
}, [prompt, selectedSku])
const handleCaptureWhiteBackground = useCallback(() => {
const handleCaptureWhiteBackground = useCallback(async () => {
if (generating) return
if (!selectedSku || !selectedSku.modelUrl) {
setMessage('请先选择并配置一个 SKU 模型文件。')
return
@ -1057,10 +1093,63 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
spu: selectedSpu,
sku: selectedSku,
userPrompt: prompt,
aspectRatio,
})
createLocalRecord({ mode: 'white-background', imageUrl: dataUrl, prompt: lockedPrompt })
setMessage('精准白底图已由 3D 模型渲染生成,产品结构未经过 AI 重绘。')
}, [capturePreview, createLocalRecord, prompt, selectedSku, selectedSpu])
setGenerating(true)
setMessage('正在基于当前 3D 参考图生成白底图,并增强低分段圆弧细节...')
try {
const render = await createAssistedAssemblyAiRender({
prompt: lockedPrompt,
negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
imageCount,
aspectRatio,
imageSize,
referenceImageDataUrl: dataUrl,
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: selectedSpu,
sku: selectedSku,
prompt: lockedPrompt,
mode: 'white-background',
}),
assetSnapshot: buildAiRenderAssetSnapshot({
spu: selectedSpu,
sku: selectedSku,
nodeNames: modelNodeNames,
}),
})
const savedCount = appendRenderedRecords({
mode: 'white-background',
images: render.resultImages,
prompt: lockedPrompt,
})
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessage(
savedCount > 1
? `精准白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。`
: '精准白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。',
)
} else {
setMessage(render.errorMessage || 'AI 白底图请求已返回,但没有可用结果图。')
}
} catch (error) {
setMessage(error instanceof Error ? error.message : 'AI 白底图生成失败')
} finally {
setGenerating(false)
}
}, [
appendRenderedRecords,
aspectRatio,
capturePreview,
generating,
imageCount,
imageSize,
modelNodeNames,
negativePrompt,
prompt,
selectedSku,
selectedSpu,
])
const handleOptimizePrompt = useCallback(async () => {
const normalizedPrompt = prompt.trim()
@ -1076,6 +1165,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
sceneBackground: 0xffffff,
sceneGridVisible: false,
}) ?? null
const optimizationTags: Record<string, string> = {
产品锁定: '基于当前 3D 参考图优化提示词,必须保留产品结构、材质方向、孔槽、尺寸比例',
曲面增强: '把低分段圆弧当作 CAD 导出瑕疵进行视觉增强,不要重设计产品',
输出比例: `最终出图比例必须匹配 ${aspectRatio}`,
}
let accumulated = ''
await optimizeAiPromptStream(
normalizedPrompt,
@ -1099,13 +1193,14 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
(errorMessage) => {
setMessage(errorMessage)
},
optimizationTags,
)
} catch (error) {
setMessage(error instanceof Error ? error.message : '提示词优化失败')
} finally {
setOptimizing(false)
}
}, [capturePreview, optimizing, prompt])
}, [aspectRatio, capturePreview, optimizing, prompt])
const handleGenerateScene = useCallback(async () => {
if (!selectedSku || !selectedSku.modelUrl) {
@ -1135,10 +1230,11 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
spu: selectedSpu,
sku: selectedSku,
userPrompt: normalizedPrompt,
aspectRatio,
})
const render = await createAssistedAssemblyAiRender({
prompt: lockedScenePrompt,
negativePrompt: negativePrompt.trim() || null,
negativePrompt: mergeNegativePrompt(negativePrompt, CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT),
imageCount,
aspectRatio,
imageSize,
@ -1155,10 +1251,13 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
nodeNames: modelNodeNames,
}),
})
const firstImage = render.resultImages[0]?.originalUrl || render.resultImages[0]?.thumbnailUrl
if (render.status === 'SUCCEEDED' && firstImage) {
createLocalRecord({ mode: 'scene', imageUrl: firstImage, prompt: lockedScenePrompt })
setMessage('场景效果图已生成。')
const savedCount = appendRenderedRecords({
mode: 'scene',
images: render.resultImages,
prompt: lockedScenePrompt,
})
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessage(savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。')
} else {
setMessage(render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。')
}
@ -1168,9 +1267,9 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
setGenerating(false)
}
}, [
appendRenderedRecords,
aspectRatio,
capturePreview,
createLocalRecord,
imageCount,
imageSize,
modelNodeNames,
@ -1197,6 +1296,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
title="AI 出图工作台"
modelUrl={selectedSku?.modelUrl || null}
preserveDrawingBuffer
repairDisplayNormals
showFpsBadge={false}
className="h-full"
defaultShowSceneGrid={false}
@ -1319,7 +1419,7 @@ export default function AiRenderWorkbenchClient({ finishes }: AiRenderWorkbenchC
>
<AlertCircle size={14} strokeWidth={1.8} />
{selectedSku?.modelUrl
? '精准白底图直接来自 3D 模型渲染,场景图继续用当前视角做 AI 参考。'
? '精准白底图会以当前 3D 视角为参考做 AI 曲面细节增强,场景图也会锁定产品结构。'
: '当前 SKU 还没有模型文件,先上传或粘贴模型 URL。'}
</div>
}

View File

@ -1,7 +1,6 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import {
AlertCircle,
Box,
@ -259,23 +258,6 @@ export function RenderInspector({
<aside className="flex h-full w-full shrink-0 flex-col bg-white shadow-[-10px_0_40px_rgba(0,0,0,0.03)] lg:w-[480px]">
<div className="min-h-0 flex-1 overflow-y-auto border-b border-black/[0.03]">
<div className="space-y-5 border-b border-black/[0.03] p-5">
<div className="flex items-center justify-between">
<Link
href="/asset-center"
className="flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.12em] text-[#47646b] transition-opacity hover:opacity-70"
>
<Sparkles className="h-3.5 w-3.5" />
<span>返回</span>
<span>资产中心</span>
<ChevronRight className="h-3 w-3" />
</Link>
<span className="rounded-[4px] bg-black/[0.03] px-2 py-1 font-mono text-[9px] font-bold text-[#5a6062]/45">
AI RENDER
</span>
</div>
<div />
<div className="grid grid-cols-2 gap-2 rounded-[8px] bg-black/[0.025] p-1 ring-1 ring-black/[0.03]">
{[
{ key: 'product' as const, label: '产品信息' },

View File

@ -93,6 +93,7 @@ interface ModelViewerProps {
clearColor?: number;
clearAlpha?: number;
preserveDrawingBuffer?: boolean;
repairDisplayNormals?: boolean;
enableTranslateGizmo?: boolean;
gizmoMode?: 'translate' | 'rotate';
gizmoSize?: number;
@ -706,6 +707,7 @@ function ModelViewerComponent({
clearColor = 0xe4e6ea,
clearAlpha = 1,
preserveDrawingBuffer = false,
repairDisplayNormals = false,
enableTranslateGizmo = false,
gizmoMode = 'translate',
gizmoSize = 0.72,
@ -731,6 +733,7 @@ function ModelViewerComponent({
const clearColorRef = useRef(clearColor);
const clearAlphaRef = useRef(clearAlpha);
const preserveDrawingBufferRef = useRef(preserveDrawingBuffer);
const repairDisplayNormalsRef = useRef(repairDisplayNormals);
const nodePositionOffsetsRef = useRef<NodePositionOffset[]>(nodePositionOffsets);
const nodeRotationStatesRef = useRef<NodeRotationState[]>(nodeRotationStates);
const worldNodesRef = useRef<EngineWorldNode[] | undefined>(worldNodes);
@ -823,11 +826,12 @@ function ModelViewerComponent({
() =>
isWorldMode
? `world:${worldFrameMode}`
: `model:${modelUrl.trim()}:${modelDimensionsSignature}`,
: `model:${modelUrl.trim()}:${modelDimensionsSignature}:${repairDisplayNormals}`,
[
isWorldMode,
modelDimensionsSignature,
modelUrl,
repairDisplayNormals,
worldFrameMode,
],
);
@ -912,6 +916,10 @@ function ModelViewerComponent({
engineRef.current.setClearColor(clearColor, clearAlpha);
}, [clearAlpha, clearColor, preserveDrawingBuffer]);
useEffect(() => {
repairDisplayNormalsRef.current = repairDisplayNormals;
}, [repairDisplayNormals]);
useEffect(() => {
nodePositionOffsetsRef.current = nodePositionOffsets;
}, [nodePositionOffsets]);
@ -1243,8 +1251,10 @@ function ModelViewerComponent({
preserveDrawingBuffer: preserveDrawingBufferRef.current,
clearColor: clearColorRef.current,
clearAlpha: clearAlphaRef.current,
repairDisplayNormals: repairDisplayNormalsRef.current,
});
engineRef.current = engine;
engine.setRepairDisplayNormals(repairDisplayNormalsRef.current);
try {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
@ -1348,6 +1358,7 @@ function ModelViewerComponent({
currentSceneIdentity,
modelDimensionsSignature,
modelUrl,
repairDisplayNormals,
worldFrameMode,
]);
@ -2371,7 +2382,7 @@ function ModelViewerComponent({
MODEL_VIEWER_PICK_WIDE_TOLERANCE_OFFSETS,
);
}
let rawPickedNodeName =
const rawPickedNodeName =
pickAtPoint(payload.clientX, payload.clientY) ??
pickPartNameWithTolerance(
pickAtPoint,
@ -2479,7 +2490,7 @@ function ModelViewerComponent({
MODEL_VIEWER_PICK_WIDE_TOLERANCE_OFFSETS,
);
}
let rawPickedNodeName =
const rawPickedNodeName =
pickAtPoint(pickClientX, pickClientY) ??
pickPartNameWithTolerance(
pickAtPoint,
@ -2712,6 +2723,7 @@ export const ModelViewer = memo(
prevProps.clearColor === nextProps.clearColor &&
prevProps.clearAlpha === nextProps.clearAlpha &&
prevProps.preserveDrawingBuffer === nextProps.preserveDrawingBuffer &&
prevProps.repairDisplayNormals === nextProps.repairDisplayNormals &&
prevProps.enableTranslateGizmo === nextProps.enableTranslateGizmo &&
prevProps.gizmoMode === nextProps.gizmoMode &&
prevProps.gizmoSize === nextProps.gizmoSize &&

View File

@ -45,6 +45,7 @@ interface ModelPreviewPanelProps {
showFpsBadge?: boolean
showCameraActionButtons?: boolean
preserveDrawingBuffer?: boolean
repairDisplayNormals?: boolean
defaultShowSceneGrid?: boolean
showSceneGrid?: boolean
onToggleSceneGrid?: () => void
@ -140,6 +141,7 @@ export function ModelPreviewPanel({
showFpsBadge = true,
showCameraActionButtons = true,
preserveDrawingBuffer = false,
repairDisplayNormals = false,
defaultShowSceneGrid = false,
showSceneGrid: externalShowSceneGrid,
onToggleSceneGrid: externalOnToggleSceneGrid,
@ -373,6 +375,7 @@ export function ModelPreviewPanel({
onNodeRotationChange={onNodeRotationChange}
onWorldNodeTransformChange={onWorldNodeTransformChange}
preserveDrawingBuffer={preserveDrawingBuffer}
repairDisplayNormals={repairDisplayNormals}
onGizmoInteractionStart={onGizmoInteractionStart}
onGizmoInteractionEnd={onGizmoInteractionEnd}
onEngineReady={handleEngineReady}

View File

@ -3,6 +3,7 @@ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { toCreasedNormals } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { createDefaultSceneLights, setupRoomEnvironment } from "./engine-core";
import { bindEngineEvents, unbindEngineEvents } from "./engine-events";
import {
@ -38,6 +39,7 @@ const SELECTION_DEBUG_ENABLED =
process.env.NODE_ENV !== "production" &&
process.env.NEXT_PUBLIC_3D_SELECTION_DEBUG === "1";
const SELECTION_NAME_MATCH_SCORE_THRESHOLD = 650;
const DISPLAY_NORMAL_REPAIR_CREASE_ANGLE = Math.PI / 10;
interface SelectionMaterialSnapshot {
emissive: THREE.Color | null;
@ -54,6 +56,7 @@ export interface EngineOptions {
preserveDrawingBuffer?: boolean;
clearColor?: number;
clearAlpha?: number;
repairDisplayNormals?: boolean;
}
export interface NodePositionOffset {
@ -132,6 +135,7 @@ export class ThreeDEngine {
private modelOriginalMaxDim = 0;
private environmentTexture: THREE.Texture | null = null;
private sceneGrid: THREE.GridHelper | null = null;
private repairDisplayNormals: boolean;
private resizeObserver: ResizeObserver | null = null;
private lastExplodedProgress = -1;
private runtimeSelectionAliasMap: Map<string, string> = new Map();
@ -309,8 +313,10 @@ export class ThreeDEngine {
preserveDrawingBuffer = false,
clearColor = PLATFORM_VIEWPORT_CLEAR_COLOR,
clearAlpha = 1,
repairDisplayNormals = false,
} = options;
this.container = container;
this.repairDisplayNormals = repairDisplayNormals;
this.scene = new THREE.Scene();
@ -482,6 +488,58 @@ export class ThreeDEngine {
this.renderer.render(this.scene, this.camera);
};
private repairMeshDisplayNormals(mesh: THREE.Mesh): void {
if (!this.repairDisplayNormals) {
return;
}
const geometry = mesh.geometry;
if (!geometry?.attributes.position) {
return;
}
try {
const repairedGeometry = toCreasedNormals(
geometry,
DISPLAY_NORMAL_REPAIR_CREASE_ANGLE,
);
if (repairedGeometry !== geometry) {
mesh.geometry = repairedGeometry;
geometry.dispose();
}
} catch (error) {
if (SELECTION_DEBUG_ENABLED) {
console.warn("[3D Engine] Display normal repair failed", error);
}
mesh.geometry.computeVertexNormals();
}
}
private prepareMeshGeometry(mesh: THREE.Mesh): void {
const geometry = mesh.geometry;
if (!geometry) {
return;
}
if (this.repairDisplayNormals) {
this.repairMeshDisplayNormals(mesh);
} else if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
}
mesh.geometry.computeBoundingBox();
mesh.geometry.computeBoundingSphere();
}
private prepareModelDisplayGeometry(model: THREE.Group): void {
model.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) {
return;
}
this.prepareMeshGeometry(child as THREE.Mesh);
});
}
private prepareObjModel(model: THREE.Group): void {
model.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) {
@ -489,14 +547,7 @@ export class ThreeDEngine {
}
const mesh = child as THREE.Mesh;
const geometry = mesh.geometry;
if (geometry) {
if (!geometry.attributes.normal) {
geometry.computeVertexNormals();
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
}
this.prepareMeshGeometry(mesh);
const materials = Array.isArray(mesh.material)
? mesh.material
@ -773,6 +824,8 @@ export class ThreeDEngine {
}
if (extension === ".obj") {
this.prepareObjModel(model);
} else {
this.prepareModelDisplayGeometry(model);
}
resolve({ model, extension });
@ -1508,6 +1561,10 @@ export class ThreeDEngine {
this.renderer.setClearColor(color, alpha)
}
setRepairDisplayNormals(enabled: boolean): void {
this.repairDisplayNormals = enabled;
}
capturePngDataUrl(options: EngineCaptureOptions = {}): string {
const shouldOverrideClear =
options.clearColor !== undefined || options.clearAlpha !== undefined;