Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-ai-white-background-color-fix

This commit is contained in:
WuYongJie 2026-06-29 13:59:38 +08:00
commit 98e89f1573
42 changed files with 5580 additions and 3049 deletions

View File

@ -16,13 +16,13 @@ import {
Search,
Sparkles,
Trash2,
UploadCloud,
Wand2,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import { RenderRecordGallery } from './RenderRecordGallery'
import { SkuModelUploadControl } from './SkuModelUploadControl'
import type { AiRenderSku, AiRenderSpu, AiRenderTaskState, LocalRenderRecord } from './types'
interface RenderInspectorProps {
@ -487,23 +487,11 @@ export function RenderInspector({
className="h-8 min-w-0 rounded-[5px] bg-white/70 px-2 text-[11px] font-medium text-[#5a6062] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/18"
aria-label="SKU 模型 URL"
/>
<label className="flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-[5px] bg-[#2d3335] px-2.5 text-[10px] font-bold text-white transition hover:opacity-90">
<UploadCloud className="h-3.5 w-3.5" />
{uploadingSkuId === skuItem.id ? '上传中' : '上传 ZIP / 模型'}
<input
type="file"
accept=".zip,.glb,.gltf,.obj,application/zip,model/gltf-binary,model/gltf+json,model/obj"
className="hidden"
disabled={uploadingSkuId === skuItem.id}
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) {
onUploadSkuModel(skuItem.id, file)
}
}}
/>
</label>
<SkuModelUploadControl
skuId={skuItem.id}
uploading={uploadingSkuId === skuItem.id}
onUpload={onUploadSkuModel}
/>
</div>
) : null}
</div>
@ -567,7 +555,7 @@ export function RenderInspector({
<textarea
value={prompt}
onChange={(event) => onPromptChange(event.target.value)}
placeholder="描述你想要的效果,如:保留产品结构,生成真实白底产品摄影或真实室内场景,材质自然,灯光柔和"
placeholder="留空按模型原样生成白底图;可填写胡桃色木纹、拉手金色金属、加少量白底配饰或场景要求"
className="min-h-[60px] w-full resize-none rounded-[4px] bg-white/70 px-2.5 py-1.5 text-[11px] font-medium leading-[18px] text-[#2d3335] outline-none ring-1 ring-black/[0.04] transition placeholder:text-[#5a6062]/35 focus:bg-white focus:ring-[#47646b]/20 disabled:opacity-50"
/>
</div>

View File

@ -0,0 +1,46 @@
'use client'
import { useRef } from 'react'
import { UploadCloud } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SkuModelUploadControlProps {
skuId: string
uploading: boolean
onUpload: (skuId: string, file: File) => void
}
export function SkuModelUploadControl({ skuId, uploading, onUpload }: SkuModelUploadControlProps) {
const inputRef = useRef<HTMLInputElement | null>(null)
return (
<>
<input
ref={inputRef}
type="file"
accept=".zip,.glb,.gltf,.obj,application/zip,model/gltf-binary,model/gltf+json,model/obj"
className="sr-only"
disabled={uploading}
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) {
onUpload(skuId, file)
}
}}
/>
<button
type="button"
disabled={uploading}
onClick={() => inputRef.current?.click()}
className={cn(
'flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[5px] bg-[#2d3335] px-2.5 text-[10px] font-bold text-white transition hover:opacity-90',
uploading && 'cursor-wait opacity-70 hover:opacity-70',
)}
>
<UploadCloud className="h-3.5 w-3.5" />
{uploading ? '上传中' : '上传 ZIP / 模型'}
</button>
</>
)
}

View File

@ -0,0 +1,210 @@
'use client'
import type { ComponentProps, ReactNode } from 'react'
import { AlertCircle, Camera, Sparkles } from 'lucide-react'
import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop'
import { ModelPreviewPanel, type ModelPreviewCapture } from '@/components/platform/ModelPreviewPanel'
import {
ContextMenu,
ContextSubmenu,
type ContextMenuGroup,
type ContextMenuItem,
type ContextMenuPosition,
} from '@/components/ui/ContextMenu'
import { UvSettingsSubmenu } from '@/app/asset-center/PreviewSubmenus'
import { cn } from '@/lib/utils'
import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-config'
import type { ThreeDEngine } from '@/lib/3d/engine'
import type { AiRenderSkuMaterialAssignment, AiRenderUvEditorFormState } from './types'
type PreviewNodeSelectHandler = NonNullable<ComponentProps<typeof ModelPreviewPanel>['onNodeSelect']>
type PreviewNodeContextMenuHandler = NonNullable<ComponentProps<typeof ModelPreviewPanel>['onNodeContextMenu']>
type PreviewModelNodesChangeHandler = NonNullable<ComponentProps<typeof ModelPreviewPanel>['onModelNodesChange']>
interface StageSubmenuPosition {
left: number
top: number
maxHeight: number
}
interface WorkbenchPreviewStageProps {
selectedSkuId: string | null
selectedSkuModelUrl: string | null
selectedNodeName: string | null
selectedNodeNames: string[]
modelNodeNames: string[]
materialAssignments: AiRenderSkuMaterialAssignment[]
stats: {
spuCount: number
configuredSkuCount: number
skuCount: number
}
detailSelectionOverlay: ReactNode
showMaterialPanel: boolean
materialPanelMode: 'main' | 'material' | 'uv' | null
materialPanelNodeName: string | null
materialPanelPosition: ContextMenuPosition
materialPanelSubtitle: string
materialPanelHasMaterialConfig: boolean
materialSubmenuPosition: StageSubmenuPosition
uvSubmenuPosition: StageSubmenuPosition
contextMenuGroups: ContextMenuGroup[]
materialMenuItems: ContextMenuItem[]
uvEditorForm: AiRenderUvEditorFormState
uvConfigDirty: boolean
onEngineReady: (payload: { engine: ThreeDEngine; modelRoot: unknown }) => void
onCaptureReady: (capture: ModelPreviewCapture | null) => void
onModelNodesChange: PreviewModelNodesChangeHandler
onNodeSelect: PreviewNodeSelectHandler
onNodeContextMenu: PreviewNodeContextMenuHandler
onContextMenuItemClick: (item: ContextMenuItem) => void
onContextMenuHover: (item: ContextMenuItem) => void
onCloseMaterialPanel: () => void
onMaterialSelect: (item: ContextMenuItem) => void
onPatchUvEditorForm: (patch: Partial<AiRenderUvEditorFormState>) => void
onSelectUvProjectionAxis: (projectionAxis: '' | AssetVariantUvProjectionAxis) => void
onResetUvEditor: () => void
onClearUvEditor: () => void
onKeepUvDraft: () => void
}
export function WorkbenchPreviewStage({
selectedSkuId,
selectedSkuModelUrl,
selectedNodeName,
selectedNodeNames,
modelNodeNames,
materialAssignments,
stats,
detailSelectionOverlay,
showMaterialPanel,
materialPanelMode,
materialPanelNodeName,
materialPanelPosition,
materialPanelSubtitle,
materialPanelHasMaterialConfig,
materialSubmenuPosition,
uvSubmenuPosition,
contextMenuGroups,
materialMenuItems,
uvEditorForm,
uvConfigDirty,
onEngineReady,
onCaptureReady,
onModelNodesChange,
onNodeSelect,
onNodeContextMenu,
onContextMenuItemClick,
onContextMenuHover,
onCloseMaterialPanel,
onMaterialSelect,
onPatchUvEditorForm,
onSelectUvProjectionAxis,
onResetUvEditor,
onClearUvEditor,
onKeepUvDraft,
}: WorkbenchPreviewStageProps) {
return (
<section className="relative min-w-0 flex-1 bg-[#f1f4f5]">
<ModelPreviewPanel
key={selectedSkuId ?? 'empty'}
title="AI 出图工作台"
modelUrl={selectedSkuModelUrl}
preserveDrawingBuffer
repairDisplayNormals
showFpsBadge={false}
className="h-full"
defaultShowSceneGrid={false}
showCameraActionButtons
enableTranslateGizmo={false}
selectedNodeName={selectedNodeName}
selectedNodeNames={selectedNodeNames}
selectableNodeNames={modelNodeNames}
materialAssignments={materialAssignments}
emptyTitle="选择 SKU 后预览"
emptyDescription="先在右侧产品信息里选择 SKU,上传 SKU 模型文件,或者粘贴模型 URL。"
emptyIcon={<Sparkles size={30} strokeWidth={1.6} />}
emptyBackdrop={<AssetWorkbenchEmptyBackdrop />}
onEngineReady={onEngineReady}
onCaptureReady={onCaptureReady}
onModelNodesChange={onModelNodesChange}
onNodeSelect={onNodeSelect}
onNodeContextMenu={onNodeContextMenu}
overlayTopRight={
<div className="flex items-center gap-2 rounded-[8px] bg-white/82 px-3 py-2 text-[11px] font-bold text-[#5a6062] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl">
<Camera size={14} strokeWidth={1.8} />
{stats.spuCount} SPU / {stats.configuredSkuCount}/{stats.skuCount} SKU 已配置
</div>
}
overlayCenter={detailSelectionOverlay}
overlayBottomCenter={
<div
className={cn(
'flex items-center gap-2 rounded-[8px] bg-white/86 px-3 py-2 text-[11px] font-semibold shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl',
selectedSkuModelUrl ? 'text-[#5a6062]' : 'text-amber-700',
)}
>
<AlertCircle size={14} strokeWidth={1.8} />
{selectedSkuModelUrl
? '白底图会以当前 3D 视角为参考做 AI 曲面细节增强,场景图也会锁定产品结构。'
: '当前 SKU 还没有模型文件,先上传或粘贴模型 URL。'}
</div>
}
/>
{showMaterialPanel && materialPanelMode && materialPanelNodeName ? (
<ContextMenu
visible
position={materialPanelPosition}
title={materialPanelNodeName}
subtitle={materialPanelSubtitle}
groups={contextMenuGroups}
activeSubmenu={materialPanelMode === 'main' ? null : materialPanelMode}
onItemClick={onContextMenuItemClick}
onItemMouseEnter={onContextMenuHover}
onClose={onCloseMaterialPanel}
width={220}
bodyMaxHeight={256}
>
{materialPanelMode === 'material' ? (
<ContextSubmenu
visible
title="选择基材花色"
position={{
left: materialSubmenuPosition.left,
top: materialSubmenuPosition.top,
}}
items={materialMenuItems}
onItemClick={onMaterialSelect}
width={260}
bodyMaxHeight={materialSubmenuPosition.maxHeight}
zIndex={221}
/>
) : null}
{materialPanelMode === 'uv' ? (
<UvSettingsSubmenu
position={uvSubmenuPosition}
form={uvEditorForm}
onFormPatch={onPatchUvEditorForm}
onSelectProjectionAxis={onSelectUvProjectionAxis}
onReset={onResetUvEditor}
onClear={onClearUvEditor}
onKeepDraft={onKeepUvDraft}
configDirty={uvConfigDirty}
submitting={false}
nodeLabel={materialPanelNodeName}
hasMaterialConfig={materialPanelHasMaterialConfig}
/>
) : null}
</ContextMenu>
) : null}
<div className="pointer-events-none absolute bottom-6 right-8 z-10 w-[280px] rounded-[12px] bg-white/82 px-5 py-4 shadow-[0_18px_48px_rgba(45,51,53,0.10)] backdrop-blur-xl">
<p className="text-[10px] font-black uppercase tracking-[0.28em] text-[#5a6062]/50">WORKSPACE</p>
<p className="mt-1 text-[11px] leading-5 text-[#5a6062]">
先选 SKU · 改颜色贴图 · 再生成白底图或场景效果图
</p>
</div>
</section>
)
}

View File

@ -0,0 +1,206 @@
'use client'
import type { MouseEventHandler, PointerEventHandler } from 'react'
import NextImage from 'next/image'
import { Crop, Palette, Trash2 } from 'lucide-react'
import type { AiRenderMaterialPartAnnotation, AiRenderTemporaryReferenceImage } from './types'
interface WorkbenchSelectionRect {
x: number
y: number
width: number
height: number
}
interface WorkbenchReferenceOverlayProps {
detailSelectionMode: boolean
detailReference: AiRenderTemporaryReferenceImage | null
materialPartAnnotationInput: string
materialPartAnnotations: AiRenderMaterialPartAnnotation[]
previewDetailSelectionRect: WorkbenchSelectionRect | null
onMaterialPartAnnotationInputChange: (value: string) => void
onStartDetailSelectionMode: () => void
onClearDetailSelection: () => void
onAddMaterialPartAnnotation: () => void
onClearMaterialPartAnnotations: () => void
onRemoveMaterialPartAnnotation: (annotationId: string) => void
onCancelDetailSelection: () => void
onDetailSelectionPointerDown: PointerEventHandler<HTMLDivElement>
onDetailSelectionPointerMove: PointerEventHandler<HTMLDivElement>
onDetailSelectionPointerUp: PointerEventHandler<HTMLDivElement>
onDetailSelectionPointerCancel: PointerEventHandler<HTMLDivElement>
onDetailSelectionContextMenu: MouseEventHandler<HTMLDivElement>
}
export function WorkbenchReferenceOverlay({
detailSelectionMode,
detailReference,
materialPartAnnotationInput,
materialPartAnnotations,
previewDetailSelectionRect,
onMaterialPartAnnotationInputChange,
onStartDetailSelectionMode,
onClearDetailSelection,
onAddMaterialPartAnnotation,
onClearMaterialPartAnnotations,
onRemoveMaterialPartAnnotation,
onCancelDetailSelection,
onDetailSelectionPointerDown,
onDetailSelectionPointerMove,
onDetailSelectionPointerUp,
onDetailSelectionPointerCancel,
onDetailSelectionContextMenu,
}: WorkbenchReferenceOverlayProps) {
return (
<div className="pointer-events-none absolute inset-0">
<div className="pointer-events-auto absolute left-20 top-3 z-20 flex items-center gap-2">
<button
type="button"
onClick={onStartDetailSelectionMode}
className="flex items-center gap-2 rounded-[8px] bg-white/88 px-3 py-2 text-[11px] font-semibold text-[#2d3335] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl transition hover:bg-white"
>
<Crop size={14} strokeWidth={1.8} />
{detailReference ? '重选特写' : '框选特写'}
</button>
{detailReference ? (
<button
type="button"
onClick={onClearDetailSelection}
className="flex items-center gap-2 rounded-[8px] bg-white/82 px-3 py-2 text-[11px] font-semibold text-[#5a6062] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl transition hover:bg-white"
>
<Trash2 size={14} strokeWidth={1.8} />
清除
</button>
) : null}
<input
value={materialPartAnnotationInput}
onChange={(event) => onMaterialPartAnnotationInputChange(event.target.value)}
placeholder="部件说明,如:金色金属拉手"
className="h-8 w-[168px] rounded-[8px] bg-white/88 px-2.5 text-[11px] font-semibold text-[#2d3335] shadow-sm outline-none ring-1 ring-black/[0.05] backdrop-blur-xl transition placeholder:text-[#5a6062]/42 focus:bg-white focus:ring-[#47646b]/20"
/>
<button
type="button"
onClick={onAddMaterialPartAnnotation}
className="flex items-center gap-2 rounded-[8px] bg-white/88 px-3 py-2 text-[11px] font-semibold text-[#2d3335] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl transition hover:bg-white"
>
<Palette size={14} strokeWidth={1.8} />
标记部件
</button>
{materialPartAnnotations.length > 0 ? (
<button
type="button"
onClick={onClearMaterialPartAnnotations}
className="flex items-center gap-2 rounded-[8px] bg-white/82 px-3 py-2 text-[11px] font-semibold text-[#5a6062] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl transition hover:bg-white"
>
<Trash2 size={14} strokeWidth={1.8} />
清标注
</button>
) : null}
</div>
{detailReference ? (
<div className="pointer-events-auto absolute left-20 top-16 z-20 w-[188px] overflow-hidden rounded-[10px] bg-white/92 shadow-[0_16px_36px_rgba(45,51,53,0.14)] ring-1 ring-black/[0.05] backdrop-blur-xl">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-[11px] font-semibold text-[#2d3335]">已保存特写参考</span>
<span className="text-[10px] text-[#5a6062]">
{detailReference.expiresAt
? `至 ${new Date(detailReference.expiresAt).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
})}`
: '临时'}
</span>
</div>
<div className="border-t border-black/[0.05] bg-[#f8f9fa] p-2">
<NextImage
src={detailReference.url}
alt="特写参考"
width={172}
height={120}
unoptimized
className="h-[120px] w-full rounded-[8px] object-contain bg-white"
/>
</div>
</div>
) : null}
{materialPartAnnotations.length > 0 ? (
<div className="pointer-events-auto absolute left-[272px] top-16 z-20 w-[260px] overflow-hidden rounded-[10px] bg-white/92 shadow-[0_16px_36px_rgba(45,51,53,0.14)] ring-1 ring-black/[0.05] backdrop-blur-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2">
<span className="min-w-0 truncate text-[11px] font-semibold text-[#2d3335]">模型标注</span>
<span className="shrink-0 text-[10px] text-[#5a6062]">{materialPartAnnotations.length} 条</span>
</div>
<div className="max-h-[180px] space-y-1.5 overflow-auto border-t border-black/[0.05] bg-[#f8f9fa] p-2">
{materialPartAnnotations.map((annotation) => (
<div
key={annotation.id}
className="flex items-start gap-2 rounded-[8px] bg-white px-2.5 py-2 text-[11px] shadow-sm ring-1 ring-black/[0.04]"
>
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-[#2d3335]">{annotation.instruction}</div>
<div className="mt-0.5 truncate text-[10px] text-[#5a6062]">
{annotation.nodeNames.length > 1 ? `${annotation.nodeNames.length} 个部件` : annotation.nodeNames[0]}
</div>
</div>
<button
type="button"
onClick={() => onRemoveMaterialPartAnnotation(annotation.id)}
className="rounded-[6px] p-1 text-[#7f8d8e] transition hover:bg-[#f1f4f5] hover:text-[#2d3335]"
title="删除模型标注"
>
<Trash2 size={12} strokeWidth={1.8} />
</button>
</div>
))}
</div>
</div>
) : null}
{detailSelectionMode ? (
<div
className="pointer-events-auto absolute inset-0 cursor-crosshair"
onPointerDown={onDetailSelectionPointerDown}
onPointerMove={onDetailSelectionPointerMove}
onPointerUp={onDetailSelectionPointerUp}
onPointerCancel={onDetailSelectionPointerCancel}
onContextMenu={onDetailSelectionContextMenu}
>
<div className="absolute inset-0 bg-[#101719]/6" />
<div className="absolute left-1/2 top-4 -translate-x-1/2 rounded-[8px] bg-white/92 px-3 py-2 text-[11px] font-semibold text-[#2d3335] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl">
拖拽框住你要重点锁定的局部细节
</div>
<button
type="button"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onCancelDetailSelection()
}}
className="absolute left-1/2 top-14 -translate-x-1/2 rounded-[8px] bg-white/92 px-3 py-2 text-[11px] font-semibold text-[#2d3335] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl transition hover:bg-white"
>
退出框选
</button>
{previewDetailSelectionRect ? (
<div
className="absolute rounded-[10px] border-2 border-[#2563eb] bg-[#2563eb]/10 shadow-[0_0_0_9999px_rgba(16,23,25,0.10)]"
style={{
left: `${previewDetailSelectionRect.x * 100}%`,
top: `${previewDetailSelectionRect.y * 100}%`,
width: `${previewDetailSelectionRect.width * 100}%`,
height: `${previewDetailSelectionRect.height * 100}%`,
}}
>
<div className="absolute left-2 top-2 rounded-[6px] bg-[#2563eb] px-2 py-1 text-[10px] font-semibold text-white">
特写参考
</div>
</div>
) : null}
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,587 @@
'use client'
import type { CameraController, CameraViewSnapshot } from '@/lib/3d/camera-controller'
import {
createAssistedAssemblyAiRender,
type CreateAssistedAssemblyAiRenderPayload,
} from '@/lib/open-product/platform-api'
import type { AssistedAssemblyAiRenderDTO } from '@/server/services'
import type { Box3 } from 'three'
import {
buildEightViewPrompt,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT,
} from './promptBuilders'
import {
buildLocalStructureReference,
buildRotatedCameraView,
cloneCameraView,
createReferenceCaptureView,
getEightViewReferenceIndexes,
} from './referenceGeometry'
import { buildReadableStructureReferenceFromImage } from './referenceImageUtils'
import { buildTaskStartedMessage, mergeNegativePrompt } from './workbenchHelpers'
import { AI_RENDER_EIGHT_VIEW_PRESETS } from './types'
import type { AiRenderSku, AiRenderSpu, AiRenderTaskState, LocalRenderRecord } from './types'
import { buildAiRenderAssetSnapshot, buildAiRenderWorkspaceSnapshot, createId } from './utils'
type EightViewPreset = (typeof AI_RENDER_EIGHT_VIEW_PRESETS)[number]
type EightViewStructureReference = {
label: string
referenceImageDataUrl: string
}
type EightViewViewReference = {
index: number
preset: EightViewPreset
cameraView: CameraViewSnapshot
referenceImageDataUrl: string
readableStructureReferenceImageDataUrl: string | null
structureReferences: EightViewStructureReference[]
}
type CaptureReferenceOptions = {
controller?: CameraController | null
restoreAfterCapture?: boolean
}
type EightViewBaseParams = {
canGenerate: boolean
generating: boolean
selectedSku: AiRenderSku | null
selectedSpu: AiRenderSpu | null
prompt: string
negativePrompt: string
aspectRatio: string
imageSize: CreateAssistedAssemblyAiRenderPayload['imageSize']
modelNodeNames: string[]
canCapturePreview: boolean
getController: () => CameraController | null
captureReferenceAtView: (
view: CameraViewSnapshot,
options?: CaptureReferenceOptions,
) => Promise<string | null>
buildProjectedDetailReferenceFromImage: (
referenceImageDataUrl: string,
fitBounds: Box3 | null | undefined,
referenceView: CameraViewSnapshot,
) => Promise<string | null>
startRenderTask: (params: {
mode: LocalRenderRecord['mode']
skuId: string
skuName: string
imageCount: number
}) => AiRenderTaskState | null
updateRunningRenderTask: (taskId: string, progress: number, stage: string) => void
appendServerRenderRecords: (
render: AssistedAssemblyAiRenderDTO,
skuFallback?: AiRenderSku | null,
) => number
setMessageForSku: (skuId: string, nextMessage: string | null) => void
settleRenderTask: (taskId: string, nextStatus: 'succeeded' | 'failed', stage: string) => void
setMessage: (message: string | null) => void
}
type EightViewGenerationParams = EightViewBaseParams
type EightViewRegenerationParams = EightViewBaseParams & {
record: LocalRenderRecord
}
function buildEightViewNegativePrompt(negativePrompt: string): string | null {
return mergeNegativePrompt(
negativePrompt,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
)
}
async function buildLocalStructureReferenceImage(params: {
controller: CameraController
cameraView: CameraViewSnapshot
referenceImageDataUrl: string
buildProjectedDetailReferenceFromImage: EightViewBaseParams['buildProjectedDetailReferenceFromImage']
}): Promise<EightViewStructureReference | null> {
const { controller, cameraView, referenceImageDataUrl, buildProjectedDetailReferenceFromImage } =
params
const localStructureReference = buildLocalStructureReference(controller, cameraView)
if (!localStructureReference?.fitBounds) {
return null
}
const referenceCaptureView = createReferenceCaptureView(controller, cameraView)
const structureReferenceImageDataUrl = await buildProjectedDetailReferenceFromImage(
referenceImageDataUrl,
localStructureReference.fitBounds,
referenceCaptureView,
)
const readableStructureReferenceImageDataUrl = structureReferenceImageDataUrl
? await buildReadableStructureReferenceFromImage(structureReferenceImageDataUrl)
: null
if (!readableStructureReferenceImageDataUrl) {
return null
}
return {
label: localStructureReference.promptLabel,
referenceImageDataUrl: readableStructureReferenceImageDataUrl,
}
}
async function captureEightViewBatchReferences(params: {
baseView: CameraViewSnapshot
controller: CameraController
taskId: string
captureReferenceAtView: EightViewBaseParams['captureReferenceAtView']
buildProjectedDetailReferenceFromImage: EightViewBaseParams['buildProjectedDetailReferenceFromImage']
updateRunningRenderTask: EightViewBaseParams['updateRunningRenderTask']
}): Promise<EightViewViewReference[]> {
const {
baseView,
controller,
taskId,
captureReferenceAtView,
buildProjectedDetailReferenceFromImage,
updateRunningRenderTask,
} = params
const viewReferences: EightViewViewReference[] = []
for (let index = 0; index < AI_RENDER_EIGHT_VIEW_PRESETS.length; index += 1) {
const preset = AI_RENDER_EIGHT_VIEW_PRESETS[index]!
const cameraView = buildRotatedCameraView(baseView, preset.angleDeg, preset.minElevationDeg)
updateRunningRenderTask(
taskId,
12 + Math.round((index / AI_RENDER_EIGHT_VIEW_PRESETS.length) * 24),
`正在截取 ${index + 1}/8:${preset.label}`,
)
const referenceImageDataUrl = await captureReferenceAtView(cameraView, { controller })
if (!referenceImageDataUrl) {
throw new Error('当前 3D 视图无法截图')
}
const readableStructureReferenceImageDataUrl =
await buildReadableStructureReferenceFromImage(referenceImageDataUrl)
const localStructureReference = await buildLocalStructureReferenceImage({
controller,
cameraView,
referenceImageDataUrl,
buildProjectedDetailReferenceFromImage,
})
viewReferences.push({
index,
preset,
cameraView,
referenceImageDataUrl,
readableStructureReferenceImageDataUrl,
structureReferences: localStructureReference ? [localStructureReference] : [],
})
}
return viewReferences
}
async function createEightViewRender(params: {
batchId: string
preset: EightViewPreset
viewIndex: number
taskSku: AiRenderSku
taskSpu: AiRenderSpu | null
taskPrompt: string
taskNegativePrompt: string
taskAspectRatio: string
taskImageSize: CreateAssistedAssemblyAiRenderPayload['imageSize']
taskNodeNames: string[]
cameraView: CameraViewSnapshot
baseView: CameraViewSnapshot
referenceImageDataUrl: string
structureReferences: EightViewStructureReference[]
}) {
const {
batchId,
preset,
viewIndex,
taskSku,
taskSpu,
taskPrompt,
taskNegativePrompt,
taskAspectRatio,
taskImageSize,
taskNodeNames,
cameraView,
baseView,
referenceImageDataUrl,
structureReferences,
} = params
const structureReferenceLabels = structureReferences.map((item) => item.label)
const lockedPrompt = buildEightViewPrompt({
spu: taskSpu,
sku: taskSku,
userPrompt: taskPrompt,
aspectRatio: taskAspectRatio,
viewName: preset.label,
structureReferenceLabels,
})
return createAssistedAssemblyAiRender({
prompt: lockedPrompt,
negativePrompt: buildEightViewNegativePrompt(taskNegativePrompt),
imageCount: 1,
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
mode: 'eight-view',
batchId,
viewName: preset.label,
viewIndex,
workbenchSkuId: taskSku.id,
referenceImageDataUrl,
structureReferenceImageDataUrls: structureReferences.map((item) => item.referenceImageDataUrl),
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: taskSpu,
sku: taskSku,
prompt: lockedPrompt,
mode: 'eight-view',
batchId,
viewName: preset.label,
viewIndex,
cameraView: cloneCameraView(cameraView),
baseCameraView: cloneCameraView(baseView),
structureReferenceViewNames: structureReferenceLabels,
}),
assetSnapshot: buildAiRenderAssetSnapshot({
spu: taskSpu,
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
}
function resolveEightViewPreset(record: LocalRenderRecord): {
presetIndex: number
preset: EightViewPreset | null
} {
const presetIndex =
typeof record.viewIndex === 'number'
? record.viewIndex
: AI_RENDER_EIGHT_VIEW_PRESETS.findIndex((preset) => preset.label === record.viewName)
return {
presetIndex,
preset: AI_RENDER_EIGHT_VIEW_PRESETS[presetIndex] ?? null,
}
}
async function captureRegenerationStructureReferences(params: {
baseView: CameraViewSnapshot
cameraView: CameraViewSnapshot
controller: CameraController
presetIndex: number
taskId: string
referenceImageDataUrl: string
captureReferenceAtView: EightViewBaseParams['captureReferenceAtView']
buildProjectedDetailReferenceFromImage: EightViewBaseParams['buildProjectedDetailReferenceFromImage']
updateRunningRenderTask: EightViewBaseParams['updateRunningRenderTask']
}): Promise<EightViewStructureReference[]> {
const {
baseView,
cameraView,
controller,
presetIndex,
taskId,
referenceImageDataUrl,
captureReferenceAtView,
buildProjectedDetailReferenceFromImage,
updateRunningRenderTask,
} = params
const structureReferences: EightViewStructureReference[] = []
const structureReferencePresets = getEightViewReferenceIndexes(presetIndex)
.map((referenceIndex) => {
const referencePreset = AI_RENDER_EIGHT_VIEW_PRESETS[referenceIndex]
return referencePreset ? { index: referenceIndex, preset: referencePreset } : null
})
.filter((item): item is { index: number; preset: EightViewPreset } => Boolean(item))
for (const item of structureReferencePresets) {
updateRunningRenderTask(taskId, 42, `正在截取结构参考:${item.preset.label}`)
const structureReferenceImageDataUrl = await captureReferenceAtView(
buildRotatedCameraView(baseView, item.preset.angleDeg, item.preset.minElevationDeg),
{ controller },
)
const readableStructureReferenceImageDataUrl = structureReferenceImageDataUrl
? await buildReadableStructureReferenceFromImage(structureReferenceImageDataUrl)
: null
if (readableStructureReferenceImageDataUrl) {
structureReferences.push({
label: item.preset.label,
referenceImageDataUrl: readableStructureReferenceImageDataUrl,
})
}
}
const localStructureReference = await buildLocalStructureReferenceImage({
controller,
cameraView,
referenceImageDataUrl,
buildProjectedDetailReferenceFromImage,
})
if (localStructureReference) {
updateRunningRenderTask(taskId, 48, '正在截取结构参考:局部结构特写')
structureReferences.push(localStructureReference)
}
return structureReferences
}
export async function runEightViewGeneration(
params: EightViewGenerationParams,
): Promise<void> {
const {
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageSize,
modelNodeNames,
canCapturePreview,
getController,
captureReferenceAtView,
buildProjectedDetailReferenceFromImage,
startRenderTask,
updateRunningRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
} = params
if (!canGenerate || generating) return
const taskSku = selectedSku
const taskSpu = selectedSpu
const controller = getController()
if (!taskSku || !taskSku.modelUrl) {
setMessage('请先选择并配置一个 SKU 模型文件。')
return
}
if (!controller || !canCapturePreview) {
setMessage('当前 3D 预览还没准备好,稍等模型加载完成再试。')
return
}
const baseView = controller.saveView()
const batchId = createId('eight-view')
const taskNodeNames = [...modelNodeNames]
const taskAspectRatio = aspectRatio
const taskImageSize = imageSize
const taskNegativePrompt = negativePrompt
const task = startRenderTask({
mode: 'eight-view',
skuId: taskSku.id,
skuName: taskSku.name || '未命名 SKU',
imageCount: AI_RENDER_EIGHT_VIEW_PRESETS.length,
})
if (!task) {
setMessage('当前 SKU 已有生图任务在后台运行。')
return
}
setMessage(buildTaskStartedMessage(task))
let savedCount = 0
try {
const viewReferences = await captureEightViewBatchReferences({
baseView,
controller,
taskId: task.id,
captureReferenceAtView,
buildProjectedDetailReferenceFromImage,
updateRunningRenderTask,
})
await controller.restoreView(baseView, { transition: 'instant', duration: 0 })
for (let itemIndex = 0; itemIndex < viewReferences.length; itemIndex += 1) {
const item = viewReferences[itemIndex]!
const structureReferences = [
...getEightViewReferenceIndexes(item.index)
.map((referenceIndex) => viewReferences.find((view) => view.index === referenceIndex) ?? null)
.filter((view): view is EightViewViewReference => Boolean(view))
.filter((view) => Boolean(view.readableStructureReferenceImageDataUrl))
.map((view) => ({
label: view.preset.label,
referenceImageDataUrl: view.readableStructureReferenceImageDataUrl!,
})),
...item.structureReferences,
]
updateRunningRenderTask(
task.id,
36 + Math.round((itemIndex / viewReferences.length) * 54),
`正在生成 ${itemIndex + 1}/8:${item.preset.label}`,
)
const render = await createEightViewRender({
batchId,
preset: item.preset,
viewIndex: item.index,
taskSku,
taskSpu,
taskPrompt: prompt,
taskNegativePrompt,
taskAspectRatio,
taskImageSize,
taskNodeNames,
cameraView: item.cameraView,
baseView,
referenceImageDataUrl: item.referenceImageDataUrl,
structureReferences,
})
savedCount += appendServerRenderRecords(render, taskSku)
}
if (savedCount > 0) {
setMessageForSku(taskSku.id, `八视图已生成,共 ${savedCount} 张。`)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
return
}
setMessageForSku(taskSku.id, 'AI 请求已返回,但没有可用的八视图。')
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
} catch (error) {
await controller.restoreView(baseView, { transition: 'instant', duration: 0 }).catch(() => undefined)
setMessageForSku(taskSku.id, error instanceof Error ? error.message : '八视图生成失败')
settleRenderTask(task.id, 'failed', '生成失败,请稍后重试')
}
}
export async function runEightViewRegeneration(
params: EightViewRegenerationParams,
): Promise<void> {
const {
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageSize,
modelNodeNames,
canCapturePreview,
getController,
record,
captureReferenceAtView,
buildProjectedDetailReferenceFromImage,
startRenderTask,
updateRunningRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
} = params
if (!canGenerate || generating) return
const taskSku = selectedSku
const taskSpu = selectedSpu
const controller = getController()
if (!taskSku || !taskSku.modelUrl || taskSku.id !== record.skuId) {
setMessage('请先选中这张八视图所属的 SKU。')
return
}
if (!controller || !canCapturePreview) {
setMessage('当前 3D 预览还没准备好,稍等模型加载完成再试。')
return
}
const { presetIndex, preset } = resolveEightViewPreset(record)
if (!preset) {
setMessage('这张记录缺少八视图视角信息,无法重生成。')
return
}
const currentView = controller.saveView()
const baseView = record.baseCameraView ?? currentView
const cameraView = buildRotatedCameraView(baseView, preset.angleDeg, preset.minElevationDeg)
const batchId = record.batchId || record.serverRenderId || record.id
const taskNodeNames = [...modelNodeNames]
const taskAspectRatio = aspectRatio
const taskImageSize = imageSize
const taskNegativePrompt = negativePrompt
const task = startRenderTask({
mode: 'eight-view',
skuId: taskSku.id,
skuName: taskSku.name || '未命名 SKU',
imageCount: 1,
})
if (!task) {
setMessage('当前 SKU 已有生图任务在后台运行。')
return
}
setMessage(`正在重生成:${preset.label}`)
try {
const referenceImageDataUrl = await captureReferenceAtView(cameraView, { controller })
if (!referenceImageDataUrl) {
throw new Error('当前 3D 视图无法截图')
}
const structureReferences = await captureRegenerationStructureReferences({
baseView,
cameraView,
controller,
presetIndex,
taskId: task.id,
referenceImageDataUrl,
captureReferenceAtView,
buildProjectedDetailReferenceFromImage,
updateRunningRenderTask,
})
updateRunningRenderTask(task.id, 58, `正在重生成:${preset.label}`)
const render = await createEightViewRender({
batchId,
preset,
viewIndex: presetIndex,
taskSku,
taskSpu,
taskPrompt: prompt,
taskNegativePrompt,
taskAspectRatio,
taskImageSize,
taskNodeNames,
cameraView,
baseView,
referenceImageDataUrl,
structureReferences,
})
const savedCount = appendServerRenderRecords(render, taskSku)
await controller.restoreView(currentView, { transition: 'instant', duration: 0 })
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessageForSku(taskSku.id, `${preset.label} 已重生成。`)
settleRenderTask(task.id, 'succeeded', '已完成,生成 1 张图片')
return
}
setMessageForSku(taskSku.id, render.errorMessage || 'AI 请求已返回,但没有可用的八视图结果。')
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
} catch (error) {
await controller.restoreView(currentView, { transition: 'instant', duration: 0 }).catch(() => undefined)
setMessageForSku(taskSku.id, error instanceof Error ? error.message : '八视图重生成失败')
settleRenderTask(task.id, 'failed', '重生成失败,请稍后重试')
}
}

View File

@ -0,0 +1,63 @@
'use client'
import * as THREE from 'three'
import type { ThreeDEngine } from '@/lib/3d/engine'
import { isHighlightHelperObjectName } from '@/lib/3d/engine-highlight-resources'
import type { MaterialPartLocatorGuide } from './promptBuilders'
import { uniqueNodeNames } from './utils'
export type MaterialPartLocatorReference = {
imageDataUrl: string
guides: MaterialPartLocatorGuide[]
}
export type MeshMaterialSnapshot = {
mesh: THREE.Mesh
material: THREE.Material | THREE.Material[]
}
function isRenderableModelMesh(object: THREE.Object3D): object is THREE.Mesh {
return (object as THREE.Mesh).isMesh === true && !isHighlightHelperObjectName(object.name)
}
export function collectRenderableMeshes(object: THREE.Object3D): THREE.Mesh[] {
const meshes = new Map<string, THREE.Mesh>()
object.traverse((child) => {
if (isRenderableModelMesh(child)) {
meshes.set(child.uuid, child)
}
})
return Array.from(meshes.values())
}
export function collectMaterialLocatorTargetMeshes(
engine: ThreeDEngine | null,
modelRoot: THREE.Object3D,
nodeNames: string[],
): THREE.Mesh[] {
const targetMeshes = new Map<string, THREE.Mesh>()
const normalizedNodeNames = uniqueNodeNames(nodeNames)
if (normalizedNodeNames.length === 0) return []
normalizedNodeNames.forEach((nodeName) => {
const object = engine?.resolveObjectByNodeName(nodeName) ?? null
if (!object) return
collectRenderableMeshes(object).forEach((mesh) => {
targetMeshes.set(mesh.uuid, mesh)
})
})
if (targetMeshes.size > 0) {
return Array.from(targetMeshes.values())
}
const exactNodeNameSet = new Set(normalizedNodeNames)
modelRoot.traverse((child) => {
if (!child.name || !exactNodeNameSet.has(child.name)) return
collectRenderableMeshes(child).forEach((mesh) => {
targetMeshes.set(mesh.uuid, mesh)
})
})
return Array.from(targetMeshes.values())
}

View File

@ -0,0 +1,398 @@
'use client'
import * as THREE from 'three'
import type {
AiRenderCameraViewSnapshot,
AiRenderMaterialPartAnnotation,
AiRenderSku,
AiRenderSpu,
} from './types'
import { uniqueNodeNames } from './utils'
import { stripLegacyDefaultPrompt } from './workbenchState'
export 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.'
export 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'
export const WHITE_BACKGROUND_CLEAN_BACKGROUND_NEGATIVE_PROMPT =
'dirty background, textured background, room scene, non-white backdrop, background artifacts, material swatches, floor texture, marble floor, floor reflection, gray shadow blobs'
export const WHITE_BACKGROUND_COMPOSITION_LOCK_NEGATIVE_PROMPT =
'recentered product, reframed product, changed crop, changed scale, straightened perspective, standardized catalog angle, mirrored orientation, flipped left-right, flipped front-back visibility, swapped visible sides'
export const WHITE_BACKGROUND_REFERENCE_AUTHORITY_NEGATIVE_PROMPT =
'rotated toward auxiliary reference, copied auxiliary view, adopted left reference angle, adopted right reference angle, exposed extra side beyond primary view, changed visible-side balance from primary view'
export const WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT =
'filled-in recesses, painted cutout shadows, fake cavity graphics, missing inner depth, flattened relief depth, shallow recesses, sealed openings, softened cavity edges, erased join gaps, erased seam lines, lost thickness breaks, airbrushed panel lines, completed partial opening into full symmetric opening, standardized opening symbol, printed dark circle, symbolic cutout graphic'
export const WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_PROMPT =
'Component type lock: material or color instructions must never change what a part physically is. If the reference shows a recessed grip, cutout, opening, slot, inset pull, flush detail, carved cavity, groove, hole, or negative-space feature, keep it as that same recessed/cutout/flush structure. Do not convert it into a protruding handle, knob, ring pull, bar pull, metal plate, added hardware, decorative fitting, or any new component. Apply requested color/material only onto the exact existing visible surfaces and edges.'
export const WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT =
'changed component type, new hardware, added handle, added knob, added ring pull, added bar pull, added metal insert, protruding handle not in reference, protruding knob not in reference, cup pull not in reference, recessed grip converted to handle, cutout converted to metal pull, slot converted to knob, hole converted to hardware, invented gold hardware, redesigned handle'
export const WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_PROMPT =
'Material scope lock: when a user asks for a base color plus an accent material on a named smaller feature, keep the base material on the surrounding panel and product body. Apply the accent material only to the smallest existing visible strip, trim, inset edge, inner lip, or embedded insert that belongs to that feature. If the reference shows a recessed or cutout grip and the user asks for metal on the handle or pull, interpret that as the existing inner strip or narrow inset surface only; keep the cavity, opening shadow, and surrounding face in the base material.'
export const WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT =
'entire handle area gold, full recess gold, gold filled recess, gold filled cutout, gold filled hole, gold circular disk, gold round plate, gold medallion, gold backplate, oversized gold patch, solid gold cup pull, surrounding panel turned gold, drawer face turned gold'
export const WHITE_BACKGROUND_SAME_VIEW_STRUCTURE_LOCK_LABEL = 'same-view structure lock map'
export const WHITE_BACKGROUND_MANUAL_DETAIL_REFERENCE_LABEL = 'same-view cropped detail reference'
export const WHITE_BACKGROUND_MATERIAL_PART_LOCATOR_LABEL = 'same-view model-part material locator map'
const AI_RENDER_PROMPT_MAX_LENGTH = 5000
const WHITE_BACKGROUND_PROP_REQUEST_PATTERN =
/(配饰|摆件|道具|装饰|陈列|书|花瓶|台灯|绿植|植物|杯|餐具|靠包|抱枕|毛毯|地毯|加一|放一|摆一|add|prop|props|book|vase|lamp|plant|decor|accessor)/i
type WhiteBackgroundViewFace = 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom'
type WhiteBackgroundViewConstraint = {
label: string
lockLine: string
detailLine: string
negativePrompt: string
}
export type MaterialPartLocatorGuide = {
colorName: string
nodeNames: string[]
instruction: string
}
function formatWhiteBackgroundViewFace(face: WhiteBackgroundViewFace): string {
switch (face) {
case 'front':
return 'front'
case 'back':
return 'back'
case 'left':
return 'left'
case 'right':
return 'right'
case 'top':
return 'top'
case 'bottom':
return 'bottom'
default:
return face
}
}
function formatWhiteBackgroundFaceList(faces: WhiteBackgroundViewFace[]): string {
const labels = faces.map((face) => formatWhiteBackgroundViewFace(face))
if (labels.length === 0) return 'same visible faces'
if (labels.length === 1) return `${labels[0]} face`
if (labels.length === 2) return `${labels[0]} and ${labels[1]} faces`
return `${labels.slice(0, -1).join(', ')}, and ${labels[labels.length - 1]} faces`
}
export function normalizeWhiteBackgroundUserPrompt(value: string): string {
return stripLegacyDefaultPrompt(value)
}
function hasWhiteBackgroundPropRequest(value: string): boolean {
return WHITE_BACKGROUND_PROP_REQUEST_PATTERN.test(value)
}
export function prepareWhiteBackgroundNegativePrompt(value: string, hasUserInstruction: boolean): string {
if (!hasUserInstruction) return value
return value
.split(',')
.map((item) => item.trim())
.filter((item) => item && item.toLowerCase() !== 'extra parts')
.join(', ')
}
export function inferWhiteBackgroundViewConstraint(
cameraView: AiRenderCameraViewSnapshot | null | undefined,
): WhiteBackgroundViewConstraint | null {
if (!cameraView) return null
const position = new THREE.Vector3(...cameraView.position)
const target = new THREE.Vector3(...cameraView.target)
const offset = position.clone().sub(target)
if (offset.lengthSq() <= 1e-6) {
return null
}
offset.normalize()
const absX = Math.abs(offset.x)
const absY = Math.abs(offset.y)
const absZ = Math.abs(offset.z)
const sideFace: WhiteBackgroundViewFace = offset.x >= 0 ? 'right' : 'left'
const depthFace: WhiteBackgroundViewFace = offset.z >= 0 ? 'front' : 'back'
const topFace: WhiteBackgroundViewFace = offset.y >= 0 ? 'top' : 'bottom'
if (absY >= 0.78 && absY >= Math.max(absX, absZ) * 1.18) {
return {
label: 'top view',
lockLine:
'The primary reference image is a top view; lock the final camera angle, framing, orientation, and visible side relationships to this top view.',
detailLine:
'Keep the top surface dominant. Do not rotate into a side view, front view, back view, or low oblique angle just to reveal extra details.',
negativePrompt:
'side view, front view, back view, oblique view, changed camera angle, rotated product, different visible faces',
}
}
if (absY >= 0.3 && absX >= 0.32 && absZ >= 0.32) {
const label = `${formatWhiteBackgroundViewFace(sideFace)}-${formatWhiteBackgroundViewFace(depthFace)} isometric view`
return {
label,
lockLine:
`The primary reference image is a ${label}; lock the final camera angle, framing, orientation, and visible side relationships to this primary image.`,
detailLine:
`Keep the ${formatWhiteBackgroundFaceList([topFace, sideFace, depthFace])} visible in the final image. Do not flatten this into a pure ${formatWhiteBackgroundViewFace(sideFace)} view, pure ${formatWhiteBackgroundViewFace(depthFace)} view, or top view. Even if some structure is unclear, preserve the current view instead of rotating the product.`,
negativePrompt:
`pure ${formatWhiteBackgroundViewFace(sideFace)} view, pure ${formatWhiteBackgroundViewFace(depthFace)} view, top view, flattened side view, changed camera angle, rotated product, different visible faces`,
}
}
if (absX >= absZ * 1.22) {
const label = `${formatWhiteBackgroundViewFace(sideFace)} view`
return {
label,
lockLine:
`The primary reference image is a ${label}; lock the final camera angle, framing, orientation, and visible side relationships to this primary image.`,
detailLine:
`Keep the ${formatWhiteBackgroundFaceList(absY >= 0.2 ? [sideFace, topFace] : [sideFace])} dominant. Do not rotate this into a front view, back view, or isometric angle.`,
negativePrompt:
'front view, back view, isometric view, changed camera angle, rotated product, different visible faces',
}
}
if (absZ >= absX * 1.22) {
const label = `${formatWhiteBackgroundViewFace(depthFace)} view`
return {
label,
lockLine:
`The primary reference image is a ${label}; lock the final camera angle, framing, orientation, and visible side relationships to this primary image.`,
detailLine:
`Keep the ${formatWhiteBackgroundFaceList(absY >= 0.2 ? [depthFace, topFace] : [depthFace])} dominant. Do not rotate this into a ${formatWhiteBackgroundViewFace(sideFace)} view or isometric angle.`,
negativePrompt:
`${formatWhiteBackgroundViewFace(sideFace)} view, isometric view, changed camera angle, rotated product, different visible faces`,
}
}
const label = `${formatWhiteBackgroundViewFace(sideFace)}-${formatWhiteBackgroundViewFace(depthFace)} oblique view`
const visibleFaces = absY >= 0.24
? [topFace, sideFace, depthFace]
: [sideFace, depthFace]
return {
label,
lockLine:
`The primary reference image is a ${label}; lock the final camera angle, framing, orientation, and visible side relationships to this primary image.`,
detailLine:
`Keep the ${formatWhiteBackgroundFaceList(visibleFaces)} visible in the final image. Do not rotate this into a pure ${formatWhiteBackgroundViewFace(sideFace)} view or pure ${formatWhiteBackgroundViewFace(depthFace)} view.`,
negativePrompt:
`pure ${formatWhiteBackgroundViewFace(sideFace)} view, pure ${formatWhiteBackgroundViewFace(depthFace)} view, changed camera angle, rotated product, different visible faces`,
}
}
function isLocalDetailReferenceLabel(label: string): boolean {
return /detail|verification|cropped/i.test(label)
}
function isSameViewStructureLockReferenceLabel(label: string): boolean {
return label.trim().toLowerCase() === WHITE_BACKGROUND_SAME_VIEW_STRUCTURE_LOCK_LABEL
}
function isMaterialPartLocatorReferenceLabel(label: string): boolean {
return label.trim().toLowerCase() === WHITE_BACKGROUND_MATERIAL_PART_LOCATOR_LABEL
}
export function buildWhiteBackgroundPrompt(params: {
spu: AiRenderSpu | null
sku: AiRenderSku
userPrompt: string
aspectRatio: string
cameraView?: AiRenderCameraViewSnapshot | null
structureReferenceLabels?: string[] | null
materialPartAnnotations?: AiRenderMaterialPartAnnotation[] | null
materialPartLocatorGuides?: MaterialPartLocatorGuide[] | null
}): string {
const materialNames = uniqueNodeNames(
Object.values(params.sku.nodeMaterials ?? {}).map((material) => material.materialName),
).slice(0, 8)
const productLabel = [params.spu?.name, params.sku.name]
.map((item) => item?.trim())
.filter(Boolean)
.join(' / ')
const userPrompt = normalizeWhiteBackgroundUserPrompt(params.userPrompt)
const hasUserInstruction = userPrompt.length > 0
const allowsWhiteBackgroundProps = hasUserInstruction && hasWhiteBackgroundPropRequest(userPrompt)
const aspectRatio = params.aspectRatio.trim()
const viewConstraint = inferWhiteBackgroundViewConstraint(params.cameraView)
const structureReferenceLabels = uniqueNodeNames(params.structureReferenceLabels ?? [])
const hasSameViewStructureLock = structureReferenceLabels.some((label) =>
isSameViewStructureLockReferenceLabel(label),
)
const hasMaterialPartLocator = structureReferenceLabels.some((label) =>
isMaterialPartLocatorReferenceLabel(label),
)
const hasLocalDetailReference = structureReferenceLabels.some((label) =>
isLocalDetailReferenceLabel(label),
)
const sideStructureReferenceLabels = structureReferenceLabels.filter(
(label) =>
!isSameViewStructureLockReferenceLabel(label) &&
!isMaterialPartLocatorReferenceLabel(label) &&
!isLocalDetailReferenceLabel(label),
)
const materialPartLocatorLines = (params.materialPartLocatorGuides ?? [])
.map((guide) => {
const nodeNames = uniqueNodeNames(guide.nodeNames).slice(0, 24)
const instruction = guide.instruction.trim()
const colorName = guide.colorName.trim()
if (!colorName || nodeNames.length === 0 || !instruction) return ''
return `${colorName} marker = nodes [${nodeNames.join(', ')}] => ${instruction}`
})
.filter(Boolean)
const materialPartAnnotationLines = (params.materialPartAnnotations ?? [])
.map((annotation) => {
const nodeNames = uniqueNodeNames(annotation.nodeNames).slice(0, 32)
const instruction = annotation.instruction.trim()
if (nodeNames.length === 0 || !instruction) return ''
return `nodes [${nodeNames.join(', ')}]: ${instruction}`
})
.filter(Boolean)
const materialPartGuidanceLines =
materialPartLocatorLines.length > 0 ? materialPartLocatorLines : materialPartAnnotationLines
return [
'Create a clean e-commerce white-background product image from the primary 3D furniture reference.',
hasUserInstruction ? `User instruction, apply exactly: ${userPrompt}.` : '',
hasUserInstruction
? 'The user instruction may affect only visible surface color, material finish, wood tone, metal finish, gloss, or explicitly requested white-background props. It must not change product geometry, structure, scale, camera angle, crop, or hardware position.'
: 'No user instruction was provided, so match the reference product material, color, hardware, and finish as closely as possible.',
hasUserInstruction
? 'If the user names a part, component, hardware, color, or material, apply it only to matching parts that already exist in the primary reference or readable structure references. Never invent new handles, holes, rails, trim, hardware, accessories, seams, or decorative structures to satisfy the instruction.'
: '',
materialPartGuidanceLines.length > 0
? `User model-part annotations are authoritative semantic material guidance for selected model nodes only: ${materialPartGuidanceLines.join(' | ')}. Apply each instruction only to the exact existing highlighted/listed node/component it describes. Keep neighboring nodes, surrounding panels, cavity shadows, openings, seams, and the rest of the product in the base material from the primary reference or user instruction. If a listed node is a whole large panel, apply the instruction only to that selected node; do not invent smaller geometry or spread the accent material outside it.`
: '',
hasMaterialPartLocator
? 'A same-view model-part material locator map is included. It uses artificial bright marker colors to show exactly which existing model parts the user annotations target; light gray parts are unmarked context only. Use the locator only to map each annotation to the correct pixels/surfaces. Do not copy the locator marker colors, flat shading, gray context, lighting, or background into the final product.'
: '',
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_PROMPT,
hasUserInstruction && !allowsWhiteBackgroundProps
? 'This is a color/material/finish instruction only. Do not add books, vases, tabletop objects, scene objects, props, stains, decorative overlays, extra parts, new patterns, or new design features.'
: '',
allowsWhiteBackgroundProps
? 'White-background props are explicitly requested. Add only minimal catalog props that do not touch, hide, cover, or redesign any important product structure.'
: '',
'Lock the product body to the primary reference: same structure, proportions, seams, openings, holes, recesses, panel breaks, thickness changes, hardware positions, placement, footprint size, handedness, and visible details.',
viewConstraint?.lockLine ?? 'Keep the same camera angle and visible side relationships as the reference image.',
viewConstraint?.detailLine ?? '',
'Primary reference image controls the final camera angle, perspective, framing, composition, crop, scale, and visible side relationships. Do not recenter, zoom, rotate, mirror, straighten, or copy any secondary view.',
hasSameViewStructureLock
? 'Same-view structure lock map is a high-contrast geometry guide generated from the primary reference. Use it only to read the exact silhouette, seams, openings, recesses, panel boundaries, thickness breaks, legs, rails, shelves, holes, slots, and hardware positions from the same camera. Do not copy its black-and-white style or use it for color or material.'
: '',
hasSameViewStructureLock
? 'Priority order: primary reference controls angle, framing, scale, and visible-side balance; readable structure references control geometry readability; user instruction controls only allowed surface finish or explicitly requested white-background props; secondary references are auxiliary only.'
: '',
'Use pure white seamless background, soft product-photography lighting, controlled contrast, and a tight natural contact shadow only. No room scene, gray fog, wide shadow blob, floor texture, reflection patch, UI, or material swatches.',
'Smooth only obvious low-poly CAD faceting on intended curves. Preserve straight edges, hard corners, flat panels, holes, slots, seams, grain direction, texture placement, and part boundaries.',
hasLocalDetailReference
? 'Cropped same-view detail reference is a readable local structure map only; use it at the primary view scale, never enlarge, duplicate, complete, restyle, or turn that local feature into new hardware.'
: '',
sideStructureReferenceLabels.length > 0
? `Secondary full-view structure references (${sideStructureReferenceLabels.join(', ')}) are readable structure maps and non-primary. Use them only to understand hidden side geometry, recesses, joins, rails, panels, shelves, legs, seams, holes, slots, thickness, and layered edges. Ignore their grayscale, ink lines, edge enhancement, and lighting.`
: '',
sideStructureReferenceLabels.length > 0
? 'Project any clarified side geometry back into the exact primary camera angle and visible-side balance. Never rotate toward a secondary reference.'
: '',
aspectRatio ? `Final output must be exactly ${aspectRatio}.` : '',
productLabel ? `Product: ${productLabel}.` : '',
materialNames.length > 0 ? `Visible materials: ${materialNames.join(', ')}.` : '',
]
.filter(Boolean)
.join(' ')
.slice(0, AI_RENDER_PROMPT_MAX_LENGTH)
}
export function buildEightViewPrompt(params: {
spu: AiRenderSpu | null
sku: AiRenderSku
userPrompt: string
aspectRatio: string
viewName: string
structureReferenceLabels?: string[]
}): string {
const materialNames = uniqueNodeNames(
Object.values(params.sku.nodeMaterials ?? {}).map((material) => material.materialName),
).slice(0, 8)
const productLabel = [params.spu?.name, params.sku.name]
.map((item) => item?.trim())
.filter(Boolean)
.join(' / ')
const userPrompt = params.userPrompt.trim()
const aspectRatio = params.aspectRatio.trim()
const structureReferenceLabels = uniqueNodeNames(params.structureReferenceLabels ?? [])
const hasLocalDetailReference = structureReferenceLabels.some((label) =>
isLocalDetailReferenceLabel(label),
)
return [
'Create a standard white-background product catalog image for this furniture product view.',
`This image is the ${params.viewName} in an eight-view product set. Keep the reference camera angle, orientation, and visible side relationships.`,
'Preserve the exact handedness of this view. Do not mirror left-right, swap the visible side order, or rotate this view into a cleaner but different catalog angle.',
'Match the primary 3D reference render for final visible color, darkness, saturation, and seam contrast. Do not beautify the finish into a lighter or cleaner showroom wood tone than the reference.',
'If the product includes recessed, cut-through, layered, grooved, hollow, stepped, or relief details, preserve them as real three-dimensional structure with visible depth, thickness, and openings where present. Do not replace them with flat dark marks or painted symbols.',
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_PROMPT,
'Keep join gaps, seam lines, groove shadows, panel breaks, openings, thickness steps, and hardware edges readable from this view without over-darkening them.',
hasLocalDetailReference
? 'The same-view cropped detail reference is only a local zoom from this exact view. Use it to verify the small structure in that selected area while keeping the full-view angle, proportion, and composition governed by the main reference.'
: '',
hasLocalDetailReference
? 'Treat the cropped detail reference as local clarification only. Do not enlarge, center, complete, deepen, or stylize a local feature just because it is shown larger in the crop.'
: '',
structureReferenceLabels.length > 0
? `Use the additional structure reference images (${structureReferenceLabels.join(', ')}) only to verify details that are unclear in this view, such as recesses, join gaps, back rails, side panels, shelves, legs, seams, holes, slots, thickness changes, and layered edges. The final output must still be the ${params.viewName}, not the auxiliary views.`
: '',
structureReferenceLabels.length > 0
? 'Additional structure references may be high-contrast readable structure maps. Ignore their grayscale, ink lines, edge enhancement, crop scale, and lighting; use them only for geometry and detail relationships.'
: '',
'Use a clean pure white background, centered composition, soft studio lighting, slight natural perspective, and a subtle contact shadow only.',
'The image must help content and design staff inspect product structure, thickness, layers, openings, hardware, seams, and material direction from this view.',
'Do not redesign the product: keep exact proportions, silhouette, part count, holes, slots, handles, doors, shelves, panels, legs, and all structural details from the reference render.',
aspectRatio ? `Final output must be exactly ${aspectRatio}.` : '',
CURVE_DETAIL_ENHANCEMENT_PROMPT,
productLabel ? `Product: ${productLabel}.` : '',
materialNames.length > 0 ? `Visible materials: ${materialNames.join(', ')}.` : '',
userPrompt ? `Additional product rendering request: ${userPrompt}.` : '',
]
.filter(Boolean)
.join(' ')
.slice(0, AI_RENDER_PROMPT_MAX_LENGTH)
}
export 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())
.filter(Boolean)
.join(' / ')
const materialNames = uniqueNodeNames(
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.',
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_PROMPT,
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(', ')}.` : '',
scenePrompt ? `Scene request: ${scenePrompt}.` : '',
]
.filter(Boolean)
.join(' ')
.slice(0, AI_RENDER_PROMPT_MAX_LENGTH)
}

View File

@ -0,0 +1,82 @@
'use client'
import { optimizeAiPromptStream } from '@/lib/open-product/platform-api'
interface PromptOptimizationParams {
prompt: string
aspectRatio: string
optimizing: boolean
captureCurrentReferenceImage: () => string | null
setOptimizing: (value: boolean) => void
setOptimExplanation: (value: string) => void
setPrompt: (value: string) => void
setMessage: (value: string | null) => void
}
const PROMPT_OPTIMIZATION_TAGS: Record<string, string> = {
产品锁定: '基于当前 3D 参考图优化提示词,必须保留产品结构、材质方向、孔槽、尺寸比例',
曲面增强: '把低分段圆弧当作 CAD 导出瑕疵进行视觉增强,不要重设计产品',
}
export async function runPromptOptimization(
params: PromptOptimizationParams,
): Promise<void> {
const {
prompt,
aspectRatio,
optimizing,
captureCurrentReferenceImage,
setOptimizing,
setOptimExplanation,
setPrompt,
setMessage,
} = params
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt || optimizing) return
setOptimizing(true)
setOptimExplanation('')
setMessage(null)
try {
const referenceImageDataUrl = captureCurrentReferenceImage()
const optimizationTags: Record<string, string> = {
...PROMPT_OPTIMIZATION_TAGS,
输出比例: `最终出图比例必须匹配 ${aspectRatio}`,
}
let accumulated = ''
await optimizeAiPromptStream(
normalizedPrompt,
referenceImageDataUrl,
(chunk) => {
accumulated += chunk
const separatorIndex = accumulated.indexOf('---PROMPT---')
if (separatorIndex >= 0) {
const explanation = accumulated.slice(0, separatorIndex).trim()
const promptPart = accumulated
.slice(separatorIndex + '---PROMPT---'.length)
.trim()
if (explanation) setOptimExplanation(explanation)
if (promptPart) setPrompt(promptPart)
return
}
setOptimExplanation(accumulated.trim())
},
(_fullText, optimizedPrompt) => {
setPrompt(optimizedPrompt)
setMessage('提示词已优化,可继续编辑后生成。')
},
(errorMessage) => {
setMessage(errorMessage)
},
optimizationTags,
)
} catch (error) {
setMessage(error instanceof Error ? error.message : '提示词优化失败')
} finally {
setOptimizing(false)
}
}

View File

@ -0,0 +1,506 @@
'use client'
import * as THREE from 'three'
import { CameraController, type CameraViewSnapshot } from '@/lib/3d/camera-controller'
import { AI_RENDER_EIGHT_VIEW_PRESETS } from './types'
import type { AiRenderCameraViewSnapshot } from './types'
const AI_RENDER_EIGHT_VIEW_STRUCTURE_REFERENCE_OFFSET = 1
const WHITE_BACKGROUND_STRUCTURE_REFERENCE_YAW_OFFSETS = [-18, 18] as const
const AI_RENDER_DETAIL_REFERENCE_CAPTURE_PADDING = 1.01
const AI_RENDER_REFERENCE_CAPTURE_PADDING = 1.05
const AI_RENDER_DETAIL_SELECTION_MIN_SIZE_RATIO = 0.03
const AI_RENDER_DETAIL_SELECTION_CONTEXT_SCALE = 1.9
const AI_RENDER_DETAIL_SELECTION_CONTEXT_MIN_RATIO = 0.22
const AI_RENDER_DETAIL_SELECTION_CONTEXT_MAX_RATIO = 0.44
export type AiRenderStructureReferenceConfig = {
promptLabel: string
displayLabel: string
cameraView: CameraViewSnapshot
fitBounds?: THREE.Box3 | null
}
export type AiRenderImageCropRect = {
x: number
y: number
width: number
height: number
}
export type AiRenderNormalizedSelectionRect = {
x: number
y: number
width: number
height: number
}
export type AiRenderDetailSelectionDraft = {
startX: number
startY: number
currentX: number
currentY: number
}
export type ProjectBoundsToImageRectOptions = {
paddingX?: number
paddingY?: number
minWidthRatio?: number | null
minHeightRatio?: number | null
maxWidthRatio?: number | null
maxHeightRatio?: number | null
}
export function buildRotatedCameraView(
baseView: CameraViewSnapshot,
angleDeg: number,
minElevationDeg = 0,
): CameraViewSnapshot {
const target = new THREE.Vector3(...baseView.target)
const offset = new THREE.Vector3(...baseView.position).sub(target)
offset.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(angleDeg))
if (minElevationDeg > 0) {
const horizontalLength = Math.sqrt(offset.x * offset.x + offset.z * offset.z)
const minElevationRad = THREE.MathUtils.degToRad(minElevationDeg)
const currentElevationRad = Math.atan2(offset.y, horizontalLength || 1)
if (currentElevationRad < minElevationRad) {
const radius = offset.length() || horizontalLength || 1
const nextHorizontalLength = radius * Math.cos(minElevationRad)
const nextVerticalLength = radius * Math.sin(minElevationRad)
const horizontalScale = horizontalLength > 0 ? nextHorizontalLength / horizontalLength : 1
offset.x *= horizontalScale
offset.z *= horizontalScale
offset.y = Math.max(offset.y, nextVerticalLength)
}
}
const position = target.clone().add(offset)
return {
position: [position.x, position.y, position.z],
target: [target.x, target.y, target.z],
}
}
function getCameraElevationDeg(view: CameraViewSnapshot): number {
const target = new THREE.Vector3(...view.target)
const offset = new THREE.Vector3(...view.position).sub(target)
const horizontalLength = Math.sqrt(offset.x * offset.x + offset.z * offset.z)
return THREE.MathUtils.radToDeg(Math.atan2(offset.y, horizontalLength || 1))
}
export function clampNormalizedRatio(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, 0), 1)
}
export function buildNormalizedSelectionRect(
startX: number,
startY: number,
endX: number,
endY: number,
): AiRenderNormalizedSelectionRect | null {
const minX = clampNormalizedRatio(Math.min(startX, endX))
const maxX = clampNormalizedRatio(Math.max(startX, endX))
const minY = clampNormalizedRatio(Math.min(startY, endY))
const maxY = clampNormalizedRatio(Math.max(startY, endY))
const width = maxX - minX
const height = maxY - minY
if (
width < AI_RENDER_DETAIL_SELECTION_MIN_SIZE_RATIO ||
height < AI_RENDER_DETAIL_SELECTION_MIN_SIZE_RATIO
) {
return null
}
return {
x: minX,
y: minY,
width,
height,
}
}
export function expandNormalizedSelectionRect(
rect: AiRenderNormalizedSelectionRect,
): AiRenderNormalizedSelectionRect {
const span = Math.max(rect.width, rect.height)
const expandDimension = (value: number): number =>
Math.max(
value,
Math.min(
Math.max(
value * AI_RENDER_DETAIL_SELECTION_CONTEXT_SCALE,
span * 1.45,
AI_RENDER_DETAIL_SELECTION_CONTEXT_MIN_RATIO,
),
AI_RENDER_DETAIL_SELECTION_CONTEXT_MAX_RATIO,
),
)
const width = Math.min(1, expandDimension(rect.width))
const height = Math.min(1, expandDimension(rect.height))
const centerX = rect.x + rect.width / 2
const centerY = rect.y + rect.height / 2
return {
x: clampNormalizedRatio(Math.min(Math.max(centerX - width / 2, 0), 1 - width)),
y: clampNormalizedRatio(Math.min(Math.max(centerY - height / 2, 0), 1 - height)),
width,
height,
}
}
export function buildCropRectFromNormalizedSelection(
imageWidth: number,
imageHeight: number,
rect: AiRenderNormalizedSelectionRect | null | undefined,
): AiRenderImageCropRect | null {
if (!rect || imageWidth <= 0 || imageHeight <= 0) {
return null
}
const minX = Math.round(clampNormalizedRatio(rect.x) * imageWidth)
const minY = Math.round(clampNormalizedRatio(rect.y) * imageHeight)
const maxX = Math.round(clampNormalizedRatio(rect.x + rect.width) * imageWidth)
const maxY = Math.round(clampNormalizedRatio(rect.y + rect.height) * imageHeight)
const width = Math.max(1, Math.min(imageWidth, maxX) - Math.max(0, minX))
const height = Math.max(1, Math.min(imageHeight, maxY) - Math.max(0, minY))
if (width <= 1 || height <= 1) {
return null
}
return {
x: Math.max(0, Math.min(imageWidth - 1, minX)),
y: Math.max(0, Math.min(imageHeight - 1, minY)),
width,
height,
}
}
function listBoxCorners(box: THREE.Box3): THREE.Vector3[] {
return [
new THREE.Vector3(box.min.x, box.min.y, box.min.z),
new THREE.Vector3(box.min.x, box.min.y, box.max.z),
new THREE.Vector3(box.min.x, box.max.y, box.min.z),
new THREE.Vector3(box.min.x, box.max.y, box.max.z),
new THREE.Vector3(box.max.x, box.min.y, box.min.z),
new THREE.Vector3(box.max.x, box.min.y, box.max.z),
new THREE.Vector3(box.max.x, box.max.y, box.min.z),
new THREE.Vector3(box.max.x, box.max.y, box.max.z),
]
}
export function projectBoundsToImageRect(
box: THREE.Box3,
camera: THREE.PerspectiveCamera,
imageWidth: number,
imageHeight: number,
options?: ProjectBoundsToImageRectOptions,
): AiRenderImageCropRect | null {
if (box.isEmpty() || imageWidth <= 0 || imageHeight <= 0) {
return null
}
camera.updateProjectionMatrix()
camera.updateMatrixWorld(true)
let minProjectedX = Number.POSITIVE_INFINITY
let minProjectedY = Number.POSITIVE_INFINITY
let maxProjectedX = Number.NEGATIVE_INFINITY
let maxProjectedY = Number.NEGATIVE_INFINITY
listBoxCorners(box).forEach((corner) => {
const projected = corner.clone().project(camera)
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return
}
minProjectedX = Math.min(minProjectedX, projected.x)
minProjectedY = Math.min(minProjectedY, projected.y)
maxProjectedX = Math.max(maxProjectedX, projected.x)
maxProjectedY = Math.max(maxProjectedY, projected.y)
})
if (
!Number.isFinite(minProjectedX) ||
!Number.isFinite(minProjectedY) ||
!Number.isFinite(maxProjectedX) ||
!Number.isFinite(maxProjectedY)
) {
return null
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max)
const ndcToImageX = (value: number): number => ((value + 1) * 0.5) * imageWidth
const ndcToImageY = (value: number): number => ((1 - value) * 0.5) * imageHeight
let minX = clamp(ndcToImageX(minProjectedX), 0, imageWidth)
let maxX = clamp(ndcToImageX(maxProjectedX), 0, imageWidth)
let minY = clamp(ndcToImageY(maxProjectedY), 0, imageHeight)
let maxY = clamp(ndcToImageY(minProjectedY), 0, imageHeight)
if (maxX - minX <= 1 || maxY - minY <= 1) {
return null
}
const centerX = (minX + maxX) / 2
const centerY = (minY + maxY) / 2
const paddedWidth = (maxX - minX) * (options?.paddingX ?? 1.18)
const paddedHeight = (maxY - minY) * (options?.paddingY ?? 1.22)
minX = centerX - paddedWidth / 2
maxX = centerX + paddedWidth / 2
minY = centerY - paddedHeight / 2
maxY = centerY + paddedHeight / 2
const enforceRange = (
start: number,
end: number,
minBound: number,
maxBound: number,
constraints: {
minLength?: number | null
maxLength?: number | null
},
): [number, number] => {
let nextStart = start
let nextEnd = end
let length = nextEnd - nextStart
const center = (nextStart + nextEnd) / 2
const minLength = constraints.minLength ?? null
const maxLength = constraints.maxLength ?? null
if (typeof minLength === 'number' && Number.isFinite(minLength) && length < minLength) {
nextStart = center - minLength / 2
nextEnd = center + minLength / 2
length = minLength
}
if (typeof maxLength === 'number' && Number.isFinite(maxLength) && length > maxLength) {
nextStart = center - maxLength / 2
nextEnd = center + maxLength / 2
length = maxLength
}
if (nextStart < minBound) {
nextEnd += minBound - nextStart
nextStart = minBound
}
if (nextEnd > maxBound) {
nextStart -= nextEnd - maxBound
nextEnd = maxBound
}
nextStart = clamp(nextStart, minBound, maxBound)
nextEnd = clamp(nextEnd, minBound, maxBound)
if (nextEnd - nextStart < 1) {
return [minBound, maxBound]
}
return [nextStart, nextEnd]
}
const minWidth =
typeof options?.minWidthRatio === 'number'
? imageWidth * options.minWidthRatio
: null
const maxWidth =
typeof options?.maxWidthRatio === 'number'
? imageWidth * options.maxWidthRatio
: null
const minHeight =
typeof options?.minHeightRatio === 'number'
? imageHeight * options.minHeightRatio
: null
const maxHeight =
typeof options?.maxHeightRatio === 'number'
? imageHeight * options.maxHeightRatio
: null
;[minX, maxX] = enforceRange(minX, maxX, 0, imageWidth, {
minLength: minWidth,
maxLength: maxWidth,
})
;[minY, maxY] = enforceRange(minY, maxY, 0, imageHeight, {
minLength: minHeight,
maxLength: maxHeight,
})
const width = Math.max(1, Math.round(maxX - minX))
const height = Math.max(1, Math.round(maxY - minY))
return {
x: Math.max(0, Math.round(minX)),
y: Math.max(0, Math.round(minY)),
width: Math.min(width, imageWidth),
height: Math.min(height, imageHeight),
}
}
function buildLocalStructureReferenceBounds(
box: THREE.Box3,
baseView: CameraViewSnapshot,
): THREE.Box3 | null {
if (box.isEmpty()) {
return null
}
const target = new THREE.Vector3(...baseView.target)
const viewDirection = new THREE.Vector3(...baseView.position).sub(target)
if (viewDirection.lengthSq() <= 1e-6) {
return null
}
viewDirection.normalize()
let viewRight = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), viewDirection)
if (viewRight.lengthSq() <= 1e-6) {
viewRight = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 0, 1), viewDirection)
}
if (viewRight.lengthSq() <= 1e-6) {
return null
}
viewRight.normalize()
const viewUp = new THREE.Vector3().crossVectors(viewDirection, viewRight)
if (viewUp.lengthSq() <= 1e-6) {
return null
}
viewUp.normalize()
const center = box.getCenter(new THREE.Vector3())
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
listBoxCorners(box).forEach((corner) => {
const offset = corner.clone().sub(center)
const localX = offset.dot(viewRight)
const localY = offset.dot(viewUp)
const localZ = offset.dot(viewDirection)
minX = Math.min(minX, localX)
minY = Math.min(minY, localY)
minZ = Math.min(minZ, localZ)
maxX = Math.max(maxX, localX)
maxY = Math.max(maxY, localY)
maxZ = Math.max(maxZ, localZ)
})
const spanX = maxX - minX
const spanY = maxY - minY
const spanZ = maxZ - minZ
if (spanX <= 1e-4 || spanY <= 1e-4 || spanZ <= 1e-4) {
return null
}
const localMinX = Math.max(minX, THREE.MathUtils.lerp(minX, maxX, 0.28) - spanX * 0.02)
const localMaxX = Math.min(maxX, THREE.MathUtils.lerp(minX, maxX, 0.72) + spanX * 0.02)
const localMinY = Math.max(minY, THREE.MathUtils.lerp(minY, maxY, 0.18) - spanY * 0.02)
const localMaxY = Math.min(maxY, THREE.MathUtils.lerp(minY, maxY, 0.52) + spanY * 0.02)
const localMinZ = Math.max(minZ, THREE.MathUtils.lerp(minZ, maxZ, 0.62) - spanZ * 0.02)
const localMaxZ = maxZ
const points: THREE.Vector3[] = []
;[localMinX, localMaxX].forEach((x) => {
;[localMinY, localMaxY].forEach((y) => {
;[localMinZ, localMaxZ].forEach((z) => {
points.push(
center
.clone()
.addScaledVector(viewRight, x)
.addScaledVector(viewUp, y)
.addScaledVector(viewDirection, z),
)
})
})
})
const detailBox = new THREE.Box3().setFromPoints(points)
return detailBox.isEmpty() ? null : detailBox
}
export function getEightViewReferenceIndexes(index: number): number[] {
const total = AI_RENDER_EIGHT_VIEW_PRESETS.length
const previous = (index - AI_RENDER_EIGHT_VIEW_STRUCTURE_REFERENCE_OFFSET + total) % total
const next = (index + AI_RENDER_EIGHT_VIEW_STRUCTURE_REFERENCE_OFFSET) % total
return [previous, next]
}
export function cloneCameraView(view: CameraViewSnapshot): AiRenderCameraViewSnapshot {
return {
position: [...view.position] as [number, number, number],
target: [...view.target] as [number, number, number],
}
}
export function createReferenceCaptureView(
controller: CameraController,
view: CameraViewSnapshot,
fitBounds?: THREE.Box3 | null,
): CameraViewSnapshot {
return (
controller.buildFittedViewFromSnapshot(view, {
padding: fitBounds
? AI_RENDER_DETAIL_REFERENCE_CAPTURE_PADDING
: AI_RENDER_REFERENCE_CAPTURE_PADDING,
box: fitBounds ?? undefined,
preferBoxCenterTarget: Boolean(fitBounds),
}) ?? view
)
}
export function buildProjectionCameraFromSnapshot(
sourceCamera: THREE.PerspectiveCamera,
view: CameraViewSnapshot,
): THREE.PerspectiveCamera {
const projectionCamera = sourceCamera.clone()
projectionCamera.position.fromArray(view.position)
projectionCamera.up.copy(sourceCamera.up)
projectionCamera.lookAt(new THREE.Vector3(...view.target))
projectionCamera.updateProjectionMatrix()
projectionCamera.updateMatrixWorld(true)
projectionCamera.updateWorldMatrix(true, false)
return projectionCamera
}
export function buildLocalStructureReference(
controller: CameraController,
baseView: CameraViewSnapshot,
): AiRenderStructureReferenceConfig | null {
const detailBounds = buildLocalStructureReferenceBounds(controller.getBounds(), baseView)
if (!detailBounds) {
return null
}
return {
promptLabel: 'local detail verification reference',
displayLabel: '局部结构特写',
cameraView: baseView,
fitBounds: detailBounds,
}
}
export function buildWhiteBackgroundStructureReferences(
baseView: CameraViewSnapshot,
): AiRenderStructureReferenceConfig[] {
const target = new THREE.Vector3(...baseView.target)
const offset = new THREE.Vector3(...baseView.position).sub(target)
const normalizedOffset = offset.lengthSq() > 1e-6 ? offset.clone().normalize() : null
if (!normalizedOffset || Math.abs(normalizedOffset.y) >= 0.82) {
return []
}
const minElevationDeg = Math.max(0, Math.min(getCameraElevationDeg(baseView), 24))
return WHITE_BACKGROUND_STRUCTURE_REFERENCE_YAW_OFFSETS.map((angleDeg) => ({
promptLabel: angleDeg < 0 ? 'left-side structure reference' : 'right-side structure reference',
displayLabel: angleDeg < 0 ? '左侧结构参考' : '右侧结构参考',
cameraView: buildRotatedCameraView(baseView, angleDeg, minElevationDeg),
}))
}

View File

@ -0,0 +1,183 @@
'use client'
import * as THREE from 'three'
import type { CameraViewSnapshot } from '@/lib/3d/camera-controller'
import {
buildProjectionCameraFromSnapshot,
projectBoundsToImageRect,
type AiRenderImageCropRect,
type ProjectBoundsToImageRectOptions,
} from './referenceGeometry'
const WHITE_BACKGROUND_STRUCTURE_LOCK_REFERENCE_MAX_SIZE = 1000
export async function readReferenceImageFromDataUrl(
referenceImageDataUrl: string,
): Promise<HTMLImageElement | null> {
if (typeof window === 'undefined') return null
return new Promise<HTMLImageElement | null>((resolve) => {
const element = new window.Image()
element.onload = () => resolve(element)
element.onerror = () => resolve(null)
element.src = referenceImageDataUrl
})
}
export async function cropReferenceImageToDataUrl(
referenceImageDataUrl: string,
cropRect: AiRenderImageCropRect | null | undefined,
): Promise<string | null> {
if (!cropRect || typeof document === 'undefined') return null
const image = await readReferenceImageFromDataUrl(referenceImageDataUrl)
if (!image) return null
const canvas = document.createElement('canvas')
canvas.width = cropRect.width
canvas.height = cropRect.height
const context = canvas.getContext('2d')
if (!context) return null
context.drawImage(
image,
cropRect.x,
cropRect.y,
cropRect.width,
cropRect.height,
0,
0,
cropRect.width,
cropRect.height,
)
return canvas.toDataURL('image/png')
}
export async function buildReadableStructureReferenceFromImage(
referenceImageDataUrl: string,
): Promise<string | null> {
if (typeof document === 'undefined') return null
const image = await readReferenceImageFromDataUrl(referenceImageDataUrl)
if (!image) return null
const sourceWidth = image.naturalWidth || image.width
const sourceHeight = image.naturalHeight || image.height
if (sourceWidth <= 1 || sourceHeight <= 1) return null
const scale = Math.min(
1,
WHITE_BACKGROUND_STRUCTURE_LOCK_REFERENCE_MAX_SIZE / Math.max(sourceWidth, sourceHeight),
)
const width = Math.max(1, Math.round(sourceWidth * scale))
const height = Math.max(1, Math.round(sourceHeight * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d', { willReadFrequently: true })
if (!context) return null
context.drawImage(image, 0, 0, width, height)
const source = context.getImageData(0, 0, width, height)
const sourceData = source.data
const pixelCount = width * height
const luma = new Float32Array(pixelCount)
for (let index = 0; index < pixelCount; index += 1) {
const offset = index * 4
luma[index] =
sourceData[offset]! * 0.2126 +
sourceData[offset + 1]! * 0.7152 +
sourceData[offset + 2]! * 0.0722
}
const output = context.createImageData(width, height)
const outputData = output.data
const clampInk = (value: number, max: number) => Math.min(Math.max(value, 0), max)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = y * width + x
const offset = index * 4
const center = luma[index] ?? 255
const objectInk = clampInk((252 - center) * 3.8, 150)
let edgeInk = 0
if (x > 0 && x < width - 1 && y > 0 && y < height - 1) {
const topLeft = luma[index - width - 1] ?? center
const top = luma[index - width] ?? center
const topRight = luma[index - width + 1] ?? center
const left = luma[index - 1] ?? center
const right = luma[index + 1] ?? center
const bottomLeft = luma[index + width - 1] ?? center
const bottom = luma[index + width] ?? center
const bottomRight = luma[index + width + 1] ?? center
const gradientX = -topLeft + topRight - 2 * left + 2 * right - bottomLeft + bottomRight
const gradientY = -topLeft - 2 * top - topRight + bottomLeft + 2 * bottom + bottomRight
const localMax = Math.max(
topLeft,
top,
topRight,
left,
right,
bottomLeft,
bottom,
bottomRight,
)
const localCreaseInk = clampInk((localMax - center - 0.8) * 24, 210)
edgeInk = Math.max(
clampInk((Math.hypot(gradientX, gradientY) - 0.5) * 18, 240),
localCreaseInk,
)
}
const ink = Math.max(objectInk, edgeInk)
const value = Math.round(255 - ink)
outputData[offset] = value
outputData[offset + 1] = value
outputData[offset + 2] = value
outputData[offset + 3] = 255
}
}
context.putImageData(output, 0, 0)
return canvas.toDataURL('image/png')
}
export async function convertReferenceImageToDataUrl(
referenceImageUrl: string,
): Promise<string | null> {
const response = await fetch(referenceImageUrl)
if (!response.ok) return null
const blob = await response.blob()
return new Promise<string | null>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null)
reader.onerror = () => resolve(null)
reader.readAsDataURL(blob)
})
}
export async function buildProjectedCropRectFromImage(params: {
sourceCamera: THREE.PerspectiveCamera | null | undefined
referenceImageDataUrl: string
fitBounds: THREE.Box3 | null | undefined
referenceView: CameraViewSnapshot
options?: ProjectBoundsToImageRectOptions
}): Promise<AiRenderImageCropRect | null> {
const { sourceCamera, referenceImageDataUrl, fitBounds, referenceView, options } = params
if (!fitBounds || !sourceCamera) return null
const image = await readReferenceImageFromDataUrl(referenceImageDataUrl)
if (!image) return null
const projectionCamera = buildProjectionCameraFromSnapshot(sourceCamera, referenceView)
return projectBoundsToImageRect(
fitBounds,
projectionCamera,
image.naturalWidth,
image.naturalHeight,
options,
)
}

View File

@ -0,0 +1,177 @@
'use client'
import type { CameraController } from '@/lib/3d/camera-controller'
import {
createAssistedAssemblyAiRender,
type CreateAssistedAssemblyAiRenderPayload,
} from '@/lib/open-product/platform-api'
import {
buildLockedScenePrompt,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
} from './promptBuilders'
import { mergeNegativePrompt, buildTaskStartedMessage } from './workbenchHelpers'
import type {
AiRenderCameraViewSnapshot,
AiRenderSku,
AiRenderSpu,
AiRenderTaskState,
LocalRenderRecord,
} from './types'
import { buildAiRenderAssetSnapshot, buildAiRenderWorkspaceSnapshot } from './utils'
type SceneGenerationParams = {
canGenerate: boolean
generating: boolean
selectedSku: AiRenderSku | null
selectedSpu: AiRenderSpu | null
prompt: string
negativePrompt: string
aspectRatio: string
imageCount: number
imageSize: CreateAssistedAssemblyAiRenderPayload['imageSize']
modelNodeNames: string[]
getCurrentCameraView: () => AiRenderCameraViewSnapshot | null
getController: () => CameraController | null
captureCurrentReferenceImage: () => string | null
captureReferenceAtView: (
view: AiRenderCameraViewSnapshot,
options?: {
controller?: CameraController | null
restoreAfterCapture?: boolean
},
) => Promise<string | null>
startRenderTask: (params: {
mode: LocalRenderRecord['mode']
skuId: string
skuName: string
imageCount: number
}) => AiRenderTaskState | null
appendServerRenderRecords: (render: any, skuFallback?: AiRenderSku | null) => number
setMessageForSku: (skuId: string, nextMessage: string | null) => void
settleRenderTask: (taskId: string, nextStatus: 'succeeded' | 'failed', stage: string) => void
setMessage: (message: string | null) => void
}
export async function runSceneGeneration(params: SceneGenerationParams): Promise<void> {
const {
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageCount,
imageSize,
modelNodeNames,
getCurrentCameraView,
getController,
captureCurrentReferenceImage,
captureReferenceAtView,
startRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
} = params
if (!canGenerate || generating) return
const taskSku = selectedSku
const taskSpu = selectedSpu
const controller = getController()
if (!taskSku || !taskSku.modelUrl) {
setMessage('请先选择并配置一个 SKU 模型文件。')
return
}
const cameraView = getCurrentCameraView()
const referenceImageDataUrl =
cameraView && controller
? await captureReferenceAtView(cameraView, {
controller,
restoreAfterCapture: true,
})
: captureCurrentReferenceImage()
if (!referenceImageDataUrl) {
setMessage('当前 3D 预览还没准备好,无法作为 AI 参考图。')
return
}
const normalizedPrompt = prompt.trim()
if (!normalizedPrompt) {
setMessage('请先填写场景效果图提示词。')
return
}
const taskImageCount = imageCount
const taskAspectRatio = aspectRatio
const taskImageSize = imageSize
const taskNegativePrompt = negativePrompt
const taskNodeNames = [...modelNodeNames]
const lockedScenePrompt = buildLockedScenePrompt({
spu: taskSpu,
sku: taskSku,
userPrompt: normalizedPrompt,
aspectRatio: taskAspectRatio,
})
const task = startRenderTask({
mode: 'scene',
skuId: taskSku.id,
skuName: taskSku.name || '未命名 SKU',
imageCount: taskImageCount,
})
if (!task) {
setMessage('当前 SKU 已有生图任务在后台运行。')
return
}
setMessage(buildTaskStartedMessage(task))
try {
const render = await createAssistedAssemblyAiRender({
prompt: lockedScenePrompt,
negativePrompt: mergeNegativePrompt(
taskNegativePrompt,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
),
imageCount: taskImageCount,
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
mode: 'scene',
workbenchSkuId: taskSku.id,
referenceImageDataUrl,
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: taskSpu,
sku: taskSku,
prompt: lockedScenePrompt,
mode: 'scene',
cameraView,
}),
assetSnapshot: buildAiRenderAssetSnapshot({
spu: taskSpu,
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
const savedCount = appendServerRenderRecords(render, taskSku)
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessageForSku(
taskSku.id,
savedCount > 1 ? `场景效果图已生成,共 ${savedCount} 张。` : '场景效果图已生成。',
)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
return
}
setMessageForSku(taskSku.id, render.errorMessage || 'AI 生图请求已返回,但没有可用结果图。')
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
} catch (error) {
setMessageForSku(taskSku.id, error instanceof Error ? error.message : 'AI 生图失败')
settleRenderTask(task.id, 'failed', '生成失败,请稍后重试')
}
}

View File

@ -82,6 +82,12 @@ export interface AiRenderTemporaryReferenceImage {
temporary: boolean
}
export interface AiRenderMaterialPartAnnotation {
id: string
nodeNames: string[]
instruction: string
}
export interface LocalRenderRecord {
id: string
serverRenderId?: string

View File

@ -0,0 +1,315 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AiRenderSku, AiRenderTemporaryReferenceImage } from './types'
import {
buildCropRectFromNormalizedSelection,
buildNormalizedSelectionRect,
clampNormalizedRatio,
expandNormalizedSelectionRect,
} from './referenceGeometry'
import { cropReferenceImageToDataUrl, readReferenceImageFromDataUrl } from './referenceImageUtils'
type UseDetailSelectionControllerOptions = {
selectedSku: AiRenderSku | null
selectedSkuDetailReference: AiRenderTemporaryReferenceImage | null
exitMessage: string
captureCurrentReferenceImage: () => string | null
onCreateDetailReference: (params: {
workbenchSkuId: string
referenceImageDataUrl: string
}) => Promise<AiRenderTemporaryReferenceImage>
onDeleteDetailReference: (params: {
storageKey: string
workbenchSkuId: string | null
}) => Promise<unknown>
onCommitDetailReference: (image: AiRenderTemporaryReferenceImage | null) => void
onMessage: (message: string) => void
}
export function useDetailSelectionController(options: UseDetailSelectionControllerOptions) {
const {
selectedSku,
selectedSkuDetailReference,
exitMessage,
captureCurrentReferenceImage,
onCreateDetailReference,
onDeleteDetailReference,
onCommitDetailReference,
onMessage,
} = options
const pointerIdRef = useRef<number | null>(null)
const [detailSelectionMode, setDetailSelectionMode] = useState(false)
const [detailSelectionDraft, setDetailSelectionDraft] = useState<{
startX: number
startY: number
currentX: number
currentY: number
} | null>(null)
const cancelDetailSelectionMode = useCallback(
(message = '已取消本次特写框选。') => {
pointerIdRef.current = null
setDetailSelectionDraft(null)
setDetailSelectionMode(false)
onMessage(message)
},
[onMessage],
)
const startDetailSelectionMode = useCallback(() => {
if (!selectedSku?.id || !selectedSku.modelUrl) return
setDetailSelectionDraft(null)
setDetailSelectionMode(true)
onMessage('请在当前 3D 视图上拖出一个特写框,我会把它保存成这个 SKU 的细节参考图。')
}, [onMessage, selectedSku])
const handleExitDetailSelection = useCallback(() => {
cancelDetailSelectionMode(exitMessage)
}, [cancelDetailSelectionMode, exitMessage])
useEffect(() => {
if (!detailSelectionMode) return
const handleEsc = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
handleExitDetailSelection()
}
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('keydown', handleEsc)
}
}, [detailSelectionMode, handleExitDetailSelection])
useEffect(() => {
pointerIdRef.current = null
setDetailSelectionDraft(null)
setDetailSelectionMode(false)
}, [selectedSku?.id])
const handleClearDetailSelection = useCallback(async () => {
setDetailSelectionMode(false)
setDetailSelectionDraft(null)
try {
if (selectedSkuDetailReference?.storageKey) {
await onDeleteDetailReference({
storageKey: selectedSkuDetailReference.storageKey,
workbenchSkuId: selectedSku?.id ?? null,
})
}
onCommitDetailReference(null)
onMessage('当前 SKU 的细节参考图已清除。')
} catch (error) {
onMessage(error instanceof Error ? error.message : '细节参考图清除失败')
}
}, [
onCommitDetailReference,
onDeleteDetailReference,
onMessage,
selectedSku,
selectedSkuDetailReference,
])
const handleDetailSelectionPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!detailSelectionMode || !selectedSku?.id) return
const bounds = event.currentTarget.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) return
const startX = clampNormalizedRatio((event.clientX - bounds.left) / bounds.width)
const startY = clampNormalizedRatio((event.clientY - bounds.top) / bounds.height)
pointerIdRef.current = event.pointerId
event.currentTarget.setPointerCapture(event.pointerId)
event.preventDefault()
event.stopPropagation()
setDetailSelectionDraft({
startX,
startY,
currentX: startX,
currentY: startY,
})
},
[detailSelectionMode, selectedSku?.id],
)
const handleDetailSelectionPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!detailSelectionDraft) return
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
if (bounds.width <= 0 || bounds.height <= 0) return
const currentX = clampNormalizedRatio((event.clientX - bounds.left) / bounds.width)
const currentY = clampNormalizedRatio((event.clientY - bounds.top) / bounds.height)
event.preventDefault()
event.stopPropagation()
setDetailSelectionDraft((current) =>
current
? {
...current,
currentX,
currentY,
}
: current,
)
},
[detailSelectionDraft],
)
const finishDetailSelection = useCallback(
async (finalPoint?: { x: number; y: number }) => {
const draft = detailSelectionDraft
? {
...detailSelectionDraft,
currentX: finalPoint ? finalPoint.x : detailSelectionDraft.currentX,
currentY: finalPoint ? finalPoint.y : detailSelectionDraft.currentY,
}
: null
const taskSku = selectedSku
const previousDetailReference = selectedSkuDetailReference
setDetailSelectionDraft(null)
pointerIdRef.current = null
setDetailSelectionMode(false)
if (!draft || !taskSku?.id || !taskSku.modelUrl) return
const nextSelection = buildNormalizedSelectionRect(
draft.startX,
draft.startY,
draft.currentX,
draft.currentY,
)
const expandedSelection = nextSelection ? expandNormalizedSelectionRect(nextSelection) : null
if (!nextSelection) {
onMessage('特写框太小了,重新拖一个更明确的区域吧。')
return
}
const referenceImageDataUrl = captureCurrentReferenceImage()
if (!referenceImageDataUrl) {
onMessage('当前 3D 预览还没准备好,等模型稳定后再框一次。')
return
}
onMessage('正在保存当前 SKU 的细节参考图…')
try {
const referenceImage = await readReferenceImageFromDataUrl(referenceImageDataUrl)
const detailCropRect = referenceImage
? buildCropRectFromNormalizedSelection(
referenceImage.naturalWidth,
referenceImage.naturalHeight,
expandedSelection,
)
: null
const croppedReferenceImageDataUrl = await cropReferenceImageToDataUrl(
referenceImageDataUrl,
detailCropRect,
)
if (!croppedReferenceImageDataUrl) {
onMessage('细节参考图裁切失败,请重新框选一次。')
return
}
const savedDetailReference = await onCreateDetailReference({
workbenchSkuId: taskSku.id,
referenceImageDataUrl: croppedReferenceImageDataUrl,
})
onCommitDetailReference(savedDetailReference)
onMessage('细节参考图已保存,后面白底图会优先带这张局部参考。')
if (
previousDetailReference?.storageKey &&
previousDetailReference.storageKey !== savedDetailReference.storageKey
) {
void onDeleteDetailReference({
storageKey: previousDetailReference.storageKey,
workbenchSkuId: taskSku.id,
}).catch(() => undefined)
}
} catch (error) {
onMessage(error instanceof Error ? error.message : '细节参考图保存失败')
}
},
[
captureCurrentReferenceImage,
detailSelectionDraft,
onCommitDetailReference,
onCreateDetailReference,
onDeleteDetailReference,
onMessage,
selectedSku,
selectedSkuDetailReference,
],
)
const handleDetailSelectionPointerUp = useCallback(
async (event: React.PointerEvent<HTMLDivElement>) => {
if (pointerIdRef.current !== null && event.pointerId !== pointerIdRef.current) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
const finalPoint =
bounds.width > 0 && bounds.height > 0
? {
x: clampNormalizedRatio((event.clientX - bounds.left) / bounds.width),
y: clampNormalizedRatio((event.clientY - bounds.top) / bounds.height),
}
: undefined
event.preventDefault()
event.stopPropagation()
try {
event.currentTarget.releasePointerCapture(event.pointerId)
} catch {
// ignore capture release failures
}
await finishDetailSelection(finalPoint)
},
[finishDetailSelection],
)
const handleDetailSelectionPointerCancel = useCallback(() => {
cancelDetailSelectionMode()
}, [cancelDetailSelectionMode])
const handleDetailSelectionContextMenu = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
handleExitDetailSelection()
},
[handleExitDetailSelection],
)
const previewDetailSelectionRect = useMemo(() => {
if (!detailSelectionDraft) return null
return buildNormalizedSelectionRect(
detailSelectionDraft.startX,
detailSelectionDraft.startY,
detailSelectionDraft.currentX,
detailSelectionDraft.currentY,
)
}, [detailSelectionDraft])
return {
detailSelectionMode,
previewDetailSelectionRect,
startDetailSelectionMode,
handleExitDetailSelection,
handleClearDetailSelection,
handleDetailSelectionPointerDown,
handleDetailSelectionPointerMove,
handleDetailSelectionPointerUp,
handleDetailSelectionPointerCancel,
handleDetailSelectionContextMenu,
}
}

View File

@ -0,0 +1,543 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { calculateMenuPosition, type ContextMenuGroup, type ContextMenuItem } from '@/components/ui/ContextMenu'
import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-config'
import type { MaterialDTO } from '@/server/services'
import {
calculateSidePanelPosition,
formatMaterialTargetLabel,
formatMaterialTargetMessageLabel,
formatMaterialTargetSubtitle,
pickUvFields,
} from './workbenchHelpers'
import type { AiRenderNodeMaterial, AiRenderSku, AiRenderUvEditorFormState } from './types'
import {
createAiRenderUvEditorFormState,
createNodeMaterialFromMaterialDTO,
mergeNodeMaterialUvConfig,
parseAiRenderUvEditorFormState,
uniqueNodeNames,
} from './utils'
type MaterialPanelMode = 'main' | 'material' | 'uv'
type MaterialPanelScope = 'nodes' | 'all'
interface MaterialPanelState {
nodeName: string
targetNodeNames: string[]
scope: MaterialPanelScope
x: number
y: number
mode: MaterialPanelMode
}
type NodeMaterialPatch =
| Partial<AiRenderNodeMaterial>
| ((
currentMaterial: AiRenderNodeMaterial | null,
nodeName: string,
sku: AiRenderSku,
) => Partial<AiRenderNodeMaterial>)
interface UseMaterialPanelControllerParams {
canWriteConfig: boolean
finishes: MaterialDTO[]
modelNodeNames: string[]
selectedSku: AiRenderSku | null
setSelectedNodeName: (value: string | null) => void
setSelectedNodeNames: (value: string[]) => void
setMessage: (message: string | null) => void
updateSelectedNodeMaterials: (nodeNames: string[], patch: NodeMaterialPatch) => void
}
interface OpenMaterialPanelParams {
nodeNames: string[]
scope?: MaterialPanelScope
clientX: number
clientY: number
}
export function useMaterialPanelController(params: UseMaterialPanelControllerParams) {
const {
canWriteConfig,
finishes,
modelNodeNames,
selectedSku,
setSelectedNodeName,
setSelectedNodeNames,
setMessage,
updateSelectedNodeMaterials,
} = params
const [materialPanel, setMaterialPanel] = useState<MaterialPanelState | null>(null)
const [uvEditorForm, setUvEditorForm] = useState<AiRenderUvEditorFormState>(() =>
createAiRenderUvEditorFormState(null),
)
const [uvEditorBaselineForm, setUvEditorBaselineForm] = useState<AiRenderUvEditorFormState>(() =>
createAiRenderUvEditorFormState(null),
)
const uvEditorFormRef = useRef<AiRenderUvEditorFormState>(uvEditorForm)
const materialPanelTargetNodeNames = useMemo(() => {
if (!materialPanel) return []
const targetNodeNames =
materialPanel.scope === 'all' ? modelNodeNames : materialPanel.targetNodeNames
return uniqueNodeNames(targetNodeNames).filter((nodeName) => modelNodeNames.includes(nodeName))
}, [materialPanel, modelNodeNames])
const materialPanelSubtitle = materialPanel
? formatMaterialTargetSubtitle(materialPanelTargetNodeNames, materialPanel.scope)
: '模型部件'
const materialPanelHasMaterialConfig = selectedSku
? materialPanelTargetNodeNames.some((nodeName) => {
const material = selectedSku.nodeMaterials?.[nodeName]
return Boolean(material?.materialId || material?.textureUrl || selectedSku.materialTextureUrl)
})
: false
const materialPanelPosition = useMemo(
() => (materialPanel ? { left: materialPanel.x, top: materialPanel.y } : { left: 0, top: 0 }),
[materialPanel],
)
const materialSubmenuPosition = useMemo(
() => calculateSidePanelPosition(materialPanelPosition.left, materialPanelPosition.top, 260),
[materialPanelPosition.left, materialPanelPosition.top],
)
const uvSubmenuPosition = useMemo(
() => calculateSidePanelPosition(materialPanelPosition.left, materialPanelPosition.top, 264),
[materialPanelPosition.left, materialPanelPosition.top],
)
const contextMenuGroups = useMemo<ContextMenuGroup[]>(
() => [
{
id: 'ai-render-selection',
items: [
{
id: 'ai-render-select-all',
label: '选择整件模型',
disabled: !canWriteConfig || modelNodeNames.length === 0,
},
],
},
{
id: 'ai-render-material',
items: [
{
id: 'ai-render-change-material',
label: '快捷更改基材',
submenu: 'material',
disabled: !canWriteConfig || finishes.length === 0,
},
],
},
{
id: 'ai-render-uv',
items: [
{
id: 'ai-render-uv-settings',
label: 'UV 参数',
submenu: 'uv',
disabled: !canWriteConfig,
},
],
},
{
id: 'ai-render-save',
items: [
{
id: 'ai-render-save-draft',
label: '保存',
disabled: !canWriteConfig || !selectedSku || !materialPanel,
},
],
},
],
[canWriteConfig, finishes.length, materialPanel, modelNodeNames.length, selectedSku],
)
const materialMenuItems = useMemo<ContextMenuItem[]>(
() =>
finishes.map((finish) => ({
id: `material:${finish.id}`,
label: finish.name,
thumbnailUrl: finish.previewUrl ?? finish.thumbnailUrl ?? finish.textureUrl,
previewImageUrl: finish.previewUrl ?? finish.thumbnailUrl ?? finish.textureUrl ?? undefined,
})),
[finishes],
)
const closeMaterialPanel = useCallback(() => {
setMaterialPanel(null)
}, [])
useEffect(() => {
uvEditorFormRef.current = uvEditorForm
}, [uvEditorForm])
useEffect(() => {
if (!materialPanel) return
const handleClose = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
if (target?.closest('[data-context-menu-root="true"]')) return
closeMaterialPanel()
}
const handleEsc = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeMaterialPanel()
}
}
document.addEventListener('mousedown', handleClose)
document.addEventListener('keydown', handleEsc)
return () => {
document.removeEventListener('mousedown', handleClose)
document.removeEventListener('keydown', handleEsc)
}
}, [closeMaterialPanel, materialPanel])
const setUvEditorDraftForm = useCallback(
(nextForm: AiRenderUvEditorFormState, options?: { resetBaseline?: boolean }) => {
uvEditorFormRef.current = nextForm
setUvEditorForm(nextForm)
if (options?.resetBaseline) {
setUvEditorBaselineForm(nextForm)
}
},
[],
)
const syncUvEditorFormToSelection = useCallback(
(form: AiRenderUvEditorFormState) => {
if (!canWriteConfig) return false
if (!materialPanel || !selectedSku || materialPanelTargetNodeNames.length === 0) return false
const uvConfig = parseAiRenderUvEditorFormState(form)
const projectionAxis = form.textureProjectionAxis || null
updateSelectedNodeMaterials(materialPanelTargetNodeNames, (currentMaterial, _nodeName, sku) =>
mergeNodeMaterialUvConfig(
currentMaterial ?? {
color: sku.materialColor || '#d9d9d9',
textureUrl: sku.materialTextureUrl,
},
uvConfig,
projectionAxis,
),
)
return true
},
[
canWriteConfig,
materialPanel,
materialPanelTargetNodeNames,
selectedSku,
updateSelectedNodeMaterials,
],
)
const openMaterialPanel = useCallback(
(openParams: OpenMaterialPanelParams) => {
if (!canWriteConfig) return
const scope = openParams.scope ?? 'nodes'
const requestedNodeNames = uniqueNodeNames(openParams.nodeNames)
const targetNodeNames =
scope === 'all'
? uniqueNodeNames(modelNodeNames)
: requestedNodeNames.filter((nodeName) => modelNodeNames.includes(nodeName))
if (targetNodeNames.length === 0) {
setMaterialPanel(null)
return
}
const position = calculateMenuPosition(openParams.clientX, openParams.clientY, 220, 256)
const currentMaterial = selectedSku?.nodeMaterials?.[targetNodeNames[0]] ?? null
setSelectedNodeName(targetNodeNames[0] ?? null)
setSelectedNodeNames(targetNodeNames)
setUvEditorDraftForm(createAiRenderUvEditorFormState(currentMaterial), { resetBaseline: true })
setMaterialPanel({
nodeName: formatMaterialTargetLabel(targetNodeNames, scope),
targetNodeNames,
scope,
x: position.left,
y: position.top,
mode: 'main',
})
setMessage(
scope === 'all'
? `正在编辑整件模型:${targetNodeNames.length} 个部件`
: targetNodeNames.length > 1
? `正在编辑 ${targetNodeNames.length} 个已选部件`
: `正在编辑模型部件:${targetNodeNames[0]}`,
)
},
[
canWriteConfig,
modelNodeNames,
selectedSku,
setMessage,
setSelectedNodeName,
setSelectedNodeNames,
setUvEditorDraftForm,
],
)
const applyUvEditorDraft = useCallback(
(successMessage?: string) => {
if (!materialPanel || !selectedSku || materialPanelTargetNodeNames.length === 0) return false
syncUvEditorFormToSelection(uvEditorFormRef.current)
setUvEditorBaselineForm(uvEditorFormRef.current)
setMessage(
successMessage ??
`渲染参数已保存到 ${formatMaterialTargetMessageLabel(
materialPanelTargetNodeNames,
materialPanel.scope,
)}`,
)
return true
},
[
materialPanel,
materialPanelTargetNodeNames,
selectedSku,
setMessage,
syncUvEditorFormToSelection,
],
)
const handleContextMenuItemClick = useCallback(
(item: ContextMenuItem) => {
if (!materialPanel || !selectedSku) return
if (item.id === 'ai-render-select-all') {
const targetNodeNames = uniqueNodeNames(modelNodeNames)
if (targetNodeNames.length === 0) return
const currentMaterial = selectedSku.nodeMaterials?.[targetNodeNames[0]] ?? null
setSelectedNodeName(targetNodeNames[0] ?? null)
setSelectedNodeNames(targetNodeNames)
setUvEditorDraftForm(createAiRenderUvEditorFormState(currentMaterial), {
resetBaseline: true,
})
setMaterialPanel((current) =>
current
? {
...current,
nodeName: formatMaterialTargetLabel(targetNodeNames, 'all'),
targetNodeNames,
scope: 'all',
mode: 'main',
}
: current,
)
setMessage(`已选择整件模型:${targetNodeNames.length} 个部件`)
return
}
if (item.id === 'ai-render-change-material') {
setMaterialPanel((current) => (current ? { ...current, mode: 'material' } : current))
return
}
if (item.id === 'ai-render-uv-settings') {
const firstNodeName = materialPanelTargetNodeNames[0]
setMaterialPanel((current) => (current ? { ...current, mode: 'uv' } : current))
setUvEditorDraftForm(
createAiRenderUvEditorFormState(
firstNodeName ? selectedSku.nodeMaterials?.[firstNodeName] ?? null : null,
),
{ resetBaseline: true },
)
return
}
if (item.id === 'ai-render-save-draft') {
applyUvEditorDraft('模型部件材质与渲染参数已保存到当前工作台。')
closeMaterialPanel()
}
},
[
applyUvEditorDraft,
closeMaterialPanel,
materialPanel,
materialPanelTargetNodeNames,
modelNodeNames,
selectedSku,
setMessage,
setSelectedNodeName,
setSelectedNodeNames,
setUvEditorDraftForm,
],
)
const handleContextMenuHover = useCallback((item: ContextMenuItem) => {
if (item.submenu === 'material' || item.submenu === 'uv') {
setMaterialPanel((current) =>
current ? { ...current, mode: item.submenu as MaterialPanelMode } : current,
)
}
}, [])
const handleMaterialSelect = useCallback(
(item: ContextMenuItem) => {
if (!materialPanel || !selectedSku || materialPanelTargetNodeNames.length === 0) return
const materialId = item.id.replace(/^material:/, '')
const finish = finishes.find((candidate) => candidate.id === materialId)
if (!finish) return
const nextMaterialBase = createNodeMaterialFromMaterialDTO(finish)
const referenceMaterial = selectedSku.nodeMaterials?.[materialPanelTargetNodeNames[0]] ?? null
const nextReferenceMaterial = {
...nextMaterialBase,
...pickUvFields(referenceMaterial),
}
updateSelectedNodeMaterials(materialPanelTargetNodeNames, (currentMaterial) => ({
...nextMaterialBase,
...pickUvFields(currentMaterial),
}))
setUvEditorDraftForm(
createAiRenderUvEditorFormState(
nextReferenceMaterial,
uvEditorFormRef.current.textureProjectionAxis || null,
),
{ resetBaseline: true },
)
setMessage(
`已给 ${formatMaterialTargetMessageLabel(
materialPanelTargetNodeNames,
materialPanel.scope,
)} 替换花色:${finish.name}`,
)
},
[
finishes,
materialPanel,
materialPanelTargetNodeNames,
selectedSku,
setMessage,
setUvEditorDraftForm,
updateSelectedNodeMaterials,
],
)
const handlePatchUvEditorForm = useCallback(
(patch: Partial<AiRenderUvEditorFormState>) => {
const nextForm = { ...uvEditorFormRef.current, ...patch }
setUvEditorDraftForm(nextForm)
syncUvEditorFormToSelection(nextForm)
},
[setUvEditorDraftForm, syncUvEditorFormToSelection],
)
const handleSelectUvProjectionAxis = useCallback(
(projectionAxis: '' | AssetVariantUvProjectionAxis) => {
const firstNodeName = materialPanelTargetNodeNames[0]
const currentMaterial =
firstNodeName ? selectedSku?.nodeMaterials?.[firstNodeName] ?? null : null
const nextForm = createAiRenderUvEditorFormState(currentMaterial, projectionAxis || null)
setUvEditorDraftForm(nextForm, { resetBaseline: true })
setMessage(
projectionAxis
? `正在单独编辑 ${projectionAxis.toUpperCase()} 轴渲染参数`
: '正在编辑整体渲染参数',
)
},
[materialPanelTargetNodeNames, selectedSku, setMessage, setUvEditorDraftForm],
)
const handleKeepUvDraft = useCallback(() => {
if (!materialPanel) return
applyUvEditorDraft(
`渲染参数已暂存到 ${formatMaterialTargetMessageLabel(
materialPanelTargetNodeNames,
materialPanel.scope,
)}`,
)
}, [applyUvEditorDraft, materialPanel, materialPanelTargetNodeNames])
const handleResetUvEditor = useCallback(() => {
setUvEditorDraftForm(uvEditorBaselineForm)
syncUvEditorFormToSelection(uvEditorBaselineForm)
}, [setUvEditorDraftForm, syncUvEditorFormToSelection, uvEditorBaselineForm])
const handleClearUvEditor = useCallback(() => {
if (!materialPanel || !selectedSku || materialPanelTargetNodeNames.length === 0) return
const currentAxis = uvEditorFormRef.current.textureProjectionAxis || null
const nextForm = createAiRenderUvEditorFormState(null, currentAxis)
setUvEditorDraftForm(nextForm)
syncUvEditorFormToSelection(nextForm)
setMessage(
currentAxis
? `${currentAxis.toUpperCase()} 轴渲染参数已清空:${formatMaterialTargetMessageLabel(
materialPanelTargetNodeNames,
materialPanel.scope,
)}`
: `整体渲染参数已清空:${formatMaterialTargetMessageLabel(
materialPanelTargetNodeNames,
materialPanel.scope,
)}`,
)
}, [
materialPanel,
materialPanelTargetNodeNames,
selectedSku,
setMessage,
setUvEditorDraftForm,
syncUvEditorFormToSelection,
])
const syncMaterialPanelWithModelNodes = useCallback((nextModelNodeNames: string[]) => {
setMaterialPanel((current) => {
if (!current) return current
const targetNodeNames =
current.scope === 'all'
? nextModelNodeNames
: current.targetNodeNames.filter((nodeName) => nextModelNodeNames.includes(nodeName))
if (targetNodeNames.length === 0) return null
return {
...current,
nodeName: formatMaterialTargetLabel(targetNodeNames, current.scope),
targetNodeNames,
}
})
}, [])
const uvConfigDirty = useMemo(
() => JSON.stringify(uvEditorBaselineForm) !== JSON.stringify(uvEditorForm),
[uvEditorBaselineForm, uvEditorForm],
)
return {
materialPanel,
uvEditorForm,
uvConfigDirty,
materialPanelSubtitle,
materialPanelHasMaterialConfig,
materialPanelPosition,
materialSubmenuPosition,
uvSubmenuPosition,
contextMenuGroups,
materialMenuItems,
closeMaterialPanel,
openMaterialPanel,
handleContextMenuItemClick,
handleContextMenuHover,
handleMaterialSelect,
handlePatchUvEditorForm,
handleSelectUvProjectionAxis,
handleKeepUvDraft,
handleResetUvEditor,
handleClearUvEditor,
syncMaterialPanelWithModelNodes,
}
}

View File

@ -0,0 +1,116 @@
'use client'
import { useCallback, type Dispatch, type SetStateAction } from 'react'
import { createId, uniqueNodeNames } from './utils'
import type { AiRenderMaterialPartAnnotation, AiRenderSku } from './types'
type UseMaterialPartAnnotationsControllerOptions = {
selectedSku: AiRenderSku | null
selectedNodeName: string | null
selectedNodeNames: string[]
materialPartAnnotationInput: string
onSetAnnotationsBySkuId: Dispatch<SetStateAction<Record<string, AiRenderMaterialPartAnnotation[]>>>
onMessage: (message: string) => void
}
export function useMaterialPartAnnotationsController(
options: UseMaterialPartAnnotationsControllerOptions,
) {
const {
selectedSku,
selectedNodeName,
selectedNodeNames,
materialPartAnnotationInput,
onSetAnnotationsBySkuId,
onMessage,
} = options
const handleAddMaterialPartAnnotation = useCallback(() => {
const skuId = selectedSku?.id
if (!skuId || !selectedSku.modelUrl) return
const instruction = materialPartAnnotationInput.trim()
if (!instruction) {
onMessage('先填写这个模型部件要表达的材质或颜色,比如:金色金属拉手。')
return
}
const nodeNames = uniqueNodeNames(
selectedNodeNames.length > 0
? selectedNodeNames
: selectedNodeName
? [selectedNodeName]
: [],
)
if (nodeNames.length === 0) {
onMessage('先点选模型部件,再标注它的材质。')
return
}
onSetAnnotationsBySkuId((current) => {
const existing = current[skuId] ?? []
const nextAnnotation: AiRenderMaterialPartAnnotation = {
id: createId('material-part'),
nodeNames,
instruction,
}
return {
...current,
[skuId]: [...existing, nextAnnotation].slice(-12),
}
})
const scopeMessage = nodeNames.length > 1 ? `${nodeNames.length} 个部件` : nodeNames[0]!
onMessage(`已标注模型部件:${scopeMessage} = ${instruction}。后面白底图会把它当作部件级材质说明。`)
}, [
materialPartAnnotationInput,
onMessage,
onSetAnnotationsBySkuId,
selectedNodeName,
selectedNodeNames,
selectedSku,
])
const handleRemoveMaterialPartAnnotation = useCallback(
(annotationId: string) => {
const skuId = selectedSku?.id
if (!skuId) return
onSetAnnotationsBySkuId((current) => {
const existing = current[skuId] ?? []
const nextAnnotations = existing.filter((annotation) => annotation.id !== annotationId)
if (nextAnnotations.length === existing.length) return current
if (nextAnnotations.length === 0) {
const next = { ...current }
delete next[skuId]
return next
}
return {
...current,
[skuId]: nextAnnotations,
}
})
onMessage('模型部件标注已删除。')
},
[onMessage, onSetAnnotationsBySkuId, selectedSku?.id],
)
const handleClearMaterialPartAnnotations = useCallback(() => {
const skuId = selectedSku?.id
if (!skuId) return
onSetAnnotationsBySkuId((current) => {
if (!(skuId in current)) return current
const next = { ...current }
delete next[skuId]
return next
})
onMessage('当前 SKU 的模型部件标注已清除。')
}, [onMessage, onSetAnnotationsBySkuId, selectedSku?.id])
return {
handleAddMaterialPartAnnotation,
handleRemoveMaterialPartAnnotation,
handleClearMaterialPartAnnotations,
}
}

View File

@ -0,0 +1,196 @@
'use client'
import { useCallback } from 'react'
import { uniqueNodeNames } from './utils'
type PreviewNodeSelectPayload = {
nodeName: string | null
rawNodeName: string | null
appendToSelection: boolean
hasRaycastHit: boolean
}
type PreviewNodeContextMenuPayload = PreviewNodeSelectPayload & {
clientX: number
clientY: number
}
interface UsePreviewInteractionControllerParams {
canWriteConfig: boolean
modelNodeNames: string[]
selectedNodeNames: string[]
closeMaterialPanel: () => void
openMaterialPanel: (params: {
nodeNames: string[]
scope?: 'nodes' | 'all'
clientX: number
clientY: number
}) => void
syncMaterialPanelWithModelNodes: (nodeNames: string[]) => void
setMessage: (message: string | null) => void
setModelNodeNames: (value: string[]) => void
setSelectedNodeName: (
value: string | null | ((current: string | null) => string | null),
) => void
setSelectedNodeNames: (
value: string[] | ((current: string[]) => string[]),
) => void
}
export function usePreviewInteractionController(
params: UsePreviewInteractionControllerParams,
) {
const {
canWriteConfig,
modelNodeNames,
selectedNodeNames,
closeMaterialPanel,
openMaterialPanel,
syncMaterialPanelWithModelNodes,
setMessage,
setModelNodeNames,
setSelectedNodeName,
setSelectedNodeNames,
} = params
const resetPreviewSelection = useCallback(() => {
setModelNodeNames([])
setSelectedNodeName(null)
setSelectedNodeNames([])
closeMaterialPanel()
}, [closeMaterialPanel, setModelNodeNames, setSelectedNodeName, setSelectedNodeNames])
const handlePreviewModelNodesChange = useCallback(
(nodes: string[]) => {
setModelNodeNames(nodes)
setSelectedNodeName((current) => (current && nodes.includes(current) ? current : null))
setSelectedNodeNames((current) =>
current.filter((nodeName) => nodes.includes(nodeName)),
)
syncMaterialPanelWithModelNodes(nodes)
},
[
setModelNodeNames,
setSelectedNodeName,
setSelectedNodeNames,
syncMaterialPanelWithModelNodes,
],
)
const handlePreviewNodeSelect = useCallback(
(payload: PreviewNodeSelectPayload) => {
const nodeName =
payload.nodeName ??
(payload.rawNodeName && modelNodeNames.includes(payload.rawNodeName)
? payload.rawNodeName
: null)
closeMaterialPanel()
if (!nodeName) {
if (payload.hasRaycastHit && modelNodeNames.length > 0) {
setSelectedNodeName(modelNodeNames[0] ?? null)
setSelectedNodeNames(modelNodeNames)
setMessage(`已选择整件模型:${modelNodeNames.length} 个部件`)
return
}
if (!payload.appendToSelection) {
setSelectedNodeName(null)
setSelectedNodeNames([])
}
return
}
if (payload.appendToSelection) {
const nextNodeNames = selectedNodeNames.includes(nodeName)
? selectedNodeNames.filter((selectedName) => selectedName !== nodeName)
: uniqueNodeNames([...selectedNodeNames, nodeName])
setSelectedNodeNames(nextNodeNames)
setSelectedNodeName(
nextNodeNames.includes(nodeName) ? nodeName : nextNodeNames[0] ?? null,
)
setMessage(
nextNodeNames.length > 1
? `已多选模型部件:${nextNodeNames.length} 个部件`
: nextNodeNames.length === 1
? `已选择模型部件:${nextNodeNames[0]}`
: '已取消模型部件选择',
)
return
}
setSelectedNodeName(nodeName)
setSelectedNodeNames([nodeName])
setMessage(`已选择模型部件:${nodeName}`)
},
[
closeMaterialPanel,
modelNodeNames,
selectedNodeNames,
setMessage,
setSelectedNodeName,
setSelectedNodeNames,
],
)
const handlePreviewNodeContextMenu = useCallback(
(payload: PreviewNodeContextMenuPayload) => {
if (!canWriteConfig) return
const nodeName =
payload.nodeName ??
(payload.rawNodeName && modelNodeNames.includes(payload.rawNodeName)
? payload.rawNodeName
: null)
if (!nodeName) {
if (payload.hasRaycastHit && modelNodeNames.length > 0) {
openMaterialPanel({
nodeNames: modelNodeNames,
scope: 'all',
clientX: payload.clientX,
clientY: payload.clientY,
})
return
}
closeMaterialPanel()
return
}
const targetNodeNames = payload.appendToSelection
? (() => {
const toggledNodeNames = selectedNodeNames.includes(nodeName)
? selectedNodeNames.filter((selectedName) => selectedName !== nodeName)
: uniqueNodeNames([...selectedNodeNames, nodeName])
return toggledNodeNames.length > 0 ? toggledNodeNames : [nodeName]
})()
: selectedNodeNames.includes(nodeName) && selectedNodeNames.length > 1
? selectedNodeNames
: [nodeName]
setSelectedNodeName(nodeName)
setSelectedNodeNames(targetNodeNames)
openMaterialPanel({
nodeNames: targetNodeNames,
clientX: payload.clientX,
clientY: payload.clientY,
})
},
[
canWriteConfig,
closeMaterialPanel,
modelNodeNames,
openMaterialPanel,
selectedNodeNames,
setSelectedNodeName,
setSelectedNodeNames,
],
)
return {
resetPreviewSelection,
handlePreviewModelNodesChange,
handlePreviewNodeSelect,
handlePreviewNodeContextMenu,
}
}

View File

@ -0,0 +1,117 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
deleteAssistedAssemblyAiRender,
listAiRenderWorkbenchSkuRenders,
} from '@/lib/open-product/platform-api'
import type { AssistedAssemblyAiRenderDTO } from '@/server/services'
import { mapServerRenderToLocalRecords } from './workbenchHelpers'
import type { AiRenderSku, LocalRenderRecord } from './types'
interface UseRenderRecordsControllerParams {
canDeleteImages: boolean
selectedSku: AiRenderSku | null
setMessage: (message: string | null) => void
}
export function useRenderRecordsController(params: UseRenderRecordsControllerParams) {
const { canDeleteImages, selectedSku, setMessage } = params
const [records, setRecords] = useState<LocalRenderRecord[]>([])
const selectedSkuRecords = useMemo(() => {
if (!selectedSku) return []
return records.filter((record) => record.skuId === selectedSku.id)
}, [records, selectedSku])
const appendServerRenderRecords = useCallback(
(render: AssistedAssemblyAiRenderDTO, skuFallback?: AiRenderSku | null) => {
const nextRecords = mapServerRenderToLocalRecords(render, skuFallback)
if (nextRecords.length === 0) return 0
const skuId = nextRecords[0]?.skuId
setRecords((current) => {
const sameSkuRecords = [
...nextRecords,
...current.filter((record) => record.skuId === skuId),
].slice(0, 120)
const otherSkuRecords = current.filter((record) => record.skuId !== skuId)
return [...sameSkuRecords, ...otherSkuRecords]
})
return nextRecords.length
},
[],
)
useEffect(() => {
if (!selectedSku?.id) {
return
}
let active = true
void (async () => {
try {
const renders = await listAiRenderWorkbenchSkuRenders(selectedSku.id, 36)
if (!active) return
const nextRecords = renders.flatMap((render) =>
mapServerRenderToLocalRecords(render, selectedSku),
)
setRecords((current) => [
...nextRecords,
...current.filter((record) => record.skuId !== selectedSku.id),
])
} catch (error) {
if (active) {
setMessage(error instanceof Error ? error.message : '当前 SKU 生图记录加载失败')
}
}
})()
return () => {
active = false
}
}, [selectedSku, setMessage])
const handleDeleteRecord = useCallback(
async (recordId: string) => {
if (!canDeleteImages) return
const record = records.find((item) => item.id === recordId)
if (!record) return
try {
if (record.serverRenderId) {
await deleteAssistedAssemblyAiRender(record.serverRenderId, {
imageIndex: record.imageIndex ?? 0,
})
}
setRecords((current) => current.filter((item) => item.id !== recordId))
setMessage('生图记录已删除')
} catch (error) {
setMessage(error instanceof Error ? error.message : '生图记录删除失败')
}
},
[canDeleteImages, records, setMessage],
)
const handleRefreshRecords = useCallback(async () => {
if (!selectedSku?.id) return
try {
const renders = await listAiRenderWorkbenchSkuRenders(selectedSku.id, 36)
setRecords((current) => [
...renders.flatMap((render) => mapServerRenderToLocalRecords(render, selectedSku)),
...current.filter((record) => record.skuId !== selectedSku.id),
])
setMessage('当前 SKU 生图记录已刷新')
} catch (error) {
setMessage(error instanceof Error ? error.message : '生图记录刷新失败')
}
}, [selectedSku, setMessage])
return {
records,
selectedSkuRecords,
appendServerRenderRecords,
handleDeleteRecord,
handleRefreshRecords,
}
}

View File

@ -31,6 +31,10 @@ export function getDefaultSkuName(index: number) {
return `新 SKU ${index}`
}
export function uniqueNodeNames(nodeNames: Array<string | null | undefined>): string[] {
return Array.from(new Set(nodeNames.map((nodeName) => nodeName?.trim()).filter(Boolean) as string[]))
}
export function getProductStats(spus: AiRenderSpu[]) {
const skuCount = spus.reduce((sum, spu) => sum + spu.skus.length, 0)
const configuredSkuCount = spus.reduce(
@ -382,6 +386,10 @@ export function buildAiRenderWorkspaceSnapshot(params: {
viewName?: string | null
viewIndex?: number | null
structureReferenceViewNames?: string[] | null
materialPartAnnotations?: Array<{
nodeNames: string[]
instruction: string
}> | null
}) {
const { spu, sku, prompt, mode } = params
return {
@ -394,6 +402,7 @@ export function buildAiRenderWorkspaceSnapshot(params: {
cameraView: params.cameraView ?? null,
baseCameraView: params.baseCameraView ?? null,
structureReferenceViewNames: params.structureReferenceViewNames ?? null,
materialPartAnnotations: params.materialPartAnnotations ?? null,
spu: spu ? { id: spu.id, name: spu.name, description: spu.description } : null,
sku: sku
? {

View File

@ -0,0 +1,324 @@
'use client'
import type { CameraController, CameraViewSnapshot } from '@/lib/3d/camera-controller'
import {
createAssistedAssemblyAiRender,
type CreateAssistedAssemblyAiRenderPayload,
} from '@/lib/open-product/platform-api'
import {
buildWhiteBackgroundPrompt,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
inferWhiteBackgroundViewConstraint,
prepareWhiteBackgroundNegativePrompt,
type MaterialPartLocatorGuide,
WHITE_BACKGROUND_CLEAN_BACKGROUND_NEGATIVE_PROMPT,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_COMPOSITION_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MANUAL_DETAIL_REFERENCE_LABEL,
WHITE_BACKGROUND_MATERIAL_PART_LOCATOR_LABEL,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT,
WHITE_BACKGROUND_REFERENCE_AUTHORITY_NEGATIVE_PROMPT,
WHITE_BACKGROUND_SAME_VIEW_STRUCTURE_LOCK_LABEL,
} from './promptBuilders'
import { buildWhiteBackgroundStructureReferences } from './referenceGeometry'
import {
buildReadableStructureReferenceFromImage,
convertReferenceImageToDataUrl,
} from './referenceImageUtils'
import { mergeNegativePrompt, buildTaskStartedMessage } from './workbenchHelpers'
import type {
AiRenderCameraViewSnapshot,
AiRenderMaterialPartAnnotation,
AiRenderSku,
AiRenderSpu,
AiRenderTaskState,
AiRenderTemporaryReferenceImage,
LocalRenderRecord,
} from './types'
import { buildAiRenderAssetSnapshot, buildAiRenderWorkspaceSnapshot } from './utils'
import type { MaterialPartLocatorReference } from './materialLocator'
type WhiteBackgroundGenerationParams = {
canGenerate: boolean
generating: boolean
selectedSku: AiRenderSku | null
selectedSpu: AiRenderSpu | null
prompt: string
negativePrompt: string
aspectRatio: string
imageCount: number
imageSize: CreateAssistedAssemblyAiRenderPayload['imageSize']
modelNodeNames: string[]
detailReferenceImagesBySkuId: Record<string, AiRenderTemporaryReferenceImage>
materialPartAnnotationsBySkuId: Record<string, AiRenderMaterialPartAnnotation[]>
structureReferenceImageMaxCount: number
getCurrentCameraView: () => AiRenderCameraViewSnapshot | null
getController: () => CameraController | null
captureCurrentReferenceImage: () => string | null
captureReferenceAtView: (
view: CameraViewSnapshot | AiRenderCameraViewSnapshot,
options?: {
controller?: CameraController | null
restoreAfterCapture?: boolean
},
) => Promise<string | null>
buildMaterialPartLocatorReferenceImage: (
annotations: AiRenderMaterialPartAnnotation[],
referenceView: AiRenderCameraViewSnapshot | null,
) => Promise<MaterialPartLocatorReference | null>
commitSelectedSkuDetailReference: (image: AiRenderTemporaryReferenceImage | null) => void
startRenderTask: (params: {
mode: LocalRenderRecord['mode']
skuId: string
skuName: string
imageCount: number
}) => AiRenderTaskState | null
updateRunningRenderTask: (taskId: string, progress: number, stage: string) => void
appendServerRenderRecords: (render: any, skuFallback?: AiRenderSku | null) => number
setMessageForSku: (skuId: string, nextMessage: string | null) => void
settleRenderTask: (taskId: string, nextStatus: 'succeeded' | 'failed', stage: string) => void
setMessage: (message: string | null) => void
}
export async function runWhiteBackgroundGeneration(
params: WhiteBackgroundGenerationParams,
): Promise<void> {
const {
canGenerate,
generating,
selectedSku,
selectedSpu,
prompt,
negativePrompt,
aspectRatio,
imageCount,
imageSize,
modelNodeNames,
detailReferenceImagesBySkuId,
materialPartAnnotationsBySkuId,
structureReferenceImageMaxCount,
getCurrentCameraView,
getController,
captureCurrentReferenceImage,
captureReferenceAtView,
buildMaterialPartLocatorReferenceImage,
commitSelectedSkuDetailReference,
startRenderTask,
updateRunningRenderTask,
appendServerRenderRecords,
setMessageForSku,
settleRenderTask,
setMessage,
} = params
if (!canGenerate || generating) return
const taskSku = selectedSku
const taskSpu = selectedSpu
const controller = getController()
if (!taskSku || !taskSku.modelUrl) {
setMessage('请先选择并配置一个 SKU 模型文件。')
return
}
const cameraView = getCurrentCameraView()
const dataUrl =
cameraView && controller
? await captureReferenceAtView(cameraView, {
controller,
restoreAfterCapture: true,
})
: captureCurrentReferenceImage()
if (!dataUrl) {
setMessage('当前 3D 预览还没准备好,稍等模型加载完成再试。')
return
}
const taskImageCount = imageCount
const taskAspectRatio = aspectRatio
const taskImageSize = imageSize
const taskUserPrompt = prompt.trim()
const taskHasUserInstruction = taskUserPrompt.length > 0
const taskNegativePrompt = prepareWhiteBackgroundNegativePrompt(
negativePrompt,
taskHasUserInstruction,
)
const taskNodeNames = [...modelNodeNames]
const taskDetailReference = detailReferenceImagesBySkuId[taskSku.id] ?? null
const taskMaterialPartAnnotations = materialPartAnnotationsBySkuId[taskSku.id] ?? []
const viewConstraint = inferWhiteBackgroundViewConstraint(cameraView)
const task = startRenderTask({
mode: 'white-background',
skuId: taskSku.id,
skuName: taskSku.name || '未命名 SKU',
imageCount: taskImageCount,
})
if (!task) {
setMessage('当前 SKU 已有生图任务在后台运行。')
return
}
setMessage(buildTaskStartedMessage(task))
try {
const structureReferenceLabels: string[] = []
const structureReferenceImageDataUrls: string[] = []
let materialPartLocatorGuides: MaterialPartLocatorGuide[] = []
const sameViewStructureLockImageDataUrl =
await buildReadableStructureReferenceFromImage(dataUrl)
if (sameViewStructureLockImageDataUrl) {
structureReferenceLabels.push(WHITE_BACKGROUND_SAME_VIEW_STRUCTURE_LOCK_LABEL)
structureReferenceImageDataUrls.push(sameViewStructureLockImageDataUrl)
}
if (taskMaterialPartAnnotations.length > 0) {
updateRunningRenderTask(task.id, 20, '正在生成模型部件定位参考图')
const locatorReference = await buildMaterialPartLocatorReferenceImage(
taskMaterialPartAnnotations,
cameraView,
)
if (
locatorReference &&
structureReferenceImageDataUrls.length < structureReferenceImageMaxCount
) {
structureReferenceLabels.push(WHITE_BACKGROUND_MATERIAL_PART_LOCATOR_LABEL)
structureReferenceImageDataUrls.push(locatorReference.imageDataUrl)
materialPartLocatorGuides = locatorReference.guides
}
}
const baseView = controller?.saveView() ?? null
const sideReferenceSlots = Math.max(
0,
structureReferenceImageMaxCount -
structureReferenceImageDataUrls.length -
(taskDetailReference?.url ? 1 : 0),
)
const structureReferenceConfigs = baseView
? buildWhiteBackgroundStructureReferences(baseView).slice(0, sideReferenceSlots)
: []
try {
for (let index = 0; index < structureReferenceConfigs.length; index += 1) {
const item = structureReferenceConfigs[index]!
updateRunningRenderTask(
task.id,
18 + Math.round(((index + 1) / (structureReferenceConfigs.length + 1)) * 12),
`正在补充结构参考:${item.displayLabel}`,
)
const structureReferenceImageDataUrl = await captureReferenceAtView(item.cameraView, {
controller,
})
const readableStructureReferenceImageDataUrl = structureReferenceImageDataUrl
? await buildReadableStructureReferenceFromImage(structureReferenceImageDataUrl)
: null
if (readableStructureReferenceImageDataUrl) {
structureReferenceLabels.push(item.promptLabel)
structureReferenceImageDataUrls.push(readableStructureReferenceImageDataUrl)
}
}
} finally {
if (controller && baseView) {
await controller
.restoreView(baseView, { transition: 'instant', duration: 0 })
.catch(() => undefined)
}
}
if (taskDetailReference?.url) {
updateRunningRenderTask(task.id, 28, '正在提取手动特写参考')
const detailReferenceImageDataUrl = await convertReferenceImageToDataUrl(
taskDetailReference.url,
)
const readableDetailReferenceImageDataUrl = detailReferenceImageDataUrl
? await buildReadableStructureReferenceFromImage(detailReferenceImageDataUrl)
: null
if (!readableDetailReferenceImageDataUrl) {
commitSelectedSkuDetailReference(null)
throw new Error('当前 SKU 的手动特写参考已失效,请重新框选后再生成白底图。')
}
structureReferenceLabels.push(WHITE_BACKGROUND_MANUAL_DETAIL_REFERENCE_LABEL)
structureReferenceImageDataUrls.push(readableDetailReferenceImageDataUrl)
}
const lockedPrompt = buildWhiteBackgroundPrompt({
spu: taskSpu,
sku: taskSku,
userPrompt: taskUserPrompt,
aspectRatio: taskAspectRatio,
cameraView,
structureReferenceLabels,
materialPartAnnotations: taskMaterialPartAnnotations,
materialPartLocatorGuides,
})
const render = await createAssistedAssemblyAiRender({
prompt: lockedPrompt,
negativePrompt: mergeNegativePrompt(
taskNegativePrompt,
viewConstraint?.negativePrompt,
WHITE_BACKGROUND_COMPONENT_TYPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MATERIAL_SCOPE_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_COMPOSITION_LOCK_NEGATIVE_PROMPT,
WHITE_BACKGROUND_REFERENCE_AUTHORITY_NEGATIVE_PROMPT,
WHITE_BACKGROUND_MICRO_STRUCTURE_NEGATIVE_PROMPT,
WHITE_BACKGROUND_CLEAN_BACKGROUND_NEGATIVE_PROMPT,
CURVE_DETAIL_ENHANCEMENT_NEGATIVE_PROMPT,
),
imageCount: taskImageCount,
aspectRatio: taskAspectRatio,
imageSize: taskImageSize,
mode: 'white-background',
workbenchSkuId: taskSku.id,
referenceImageDataUrl: dataUrl,
structureReferenceImageDataUrls:
structureReferenceImageDataUrls.length > 0
? structureReferenceImageDataUrls
: undefined,
materialPartAnnotations:
taskMaterialPartAnnotations.length > 0 ? taskMaterialPartAnnotations : undefined,
workspaceSnapshot: buildAiRenderWorkspaceSnapshot({
spu: taskSpu,
sku: taskSku,
prompt: lockedPrompt,
mode: 'white-background',
cameraView,
baseCameraView: cameraView,
structureReferenceViewNames: structureReferenceLabels,
materialPartAnnotations:
taskMaterialPartAnnotations.length > 0 ? taskMaterialPartAnnotations : null,
}),
assetSnapshot: buildAiRenderAssetSnapshot({
spu: taskSpu,
sku: taskSku,
nodeNames: taskNodeNames,
}),
})
const savedCount = appendServerRenderRecords(render, taskSku)
if (render.status === 'SUCCEEDED' && savedCount > 0) {
setMessageForSku(
taskSku.id,
savedCount > 1
? `白底图已生成,共 ${savedCount} 张,并按当前模型参考增强了低分段圆弧的视觉细节。`
: '白底图已生成,并按当前模型参考增强了低分段圆弧的视觉细节。',
)
settleRenderTask(task.id, 'succeeded', `已完成,生成 ${savedCount} 张图片`)
return
}
setMessageForSku(
taskSku.id,
render.errorMessage || 'AI 白底图请求已返回,但没有可用结果图。',
)
settleRenderTask(task.id, 'failed', '生成结束,但没有返回可用图片')
} catch (error) {
setMessageForSku(
taskSku.id,
error instanceof Error ? error.message : 'AI 白底图生成失败',
)
settleRenderTask(task.id, 'failed', '生成失败,请稍后重试')
}
}

View File

@ -0,0 +1,229 @@
'use client'
import type { AssistedAssemblyAiRenderDTO } from '@/server/services'
import { toCameraViewSnapshot, toRecord } from './workbenchState'
import type { AiRenderNodeMaterial, AiRenderSku, AiRenderSpu, LocalRenderRecord } from './types'
const AI_RENDER_NEGATIVE_PROMPT_MAX_LENGTH = 1200
type MaterialPanelScope = 'nodes' | 'all'
export interface WorkbenchSidePanelPosition {
left: number
top: number
maxHeight: number
}
export function mergeNegativePrompt(...parts: Array<string | null | undefined>): string | null {
const phrases = Array.from(
new Set(
parts.flatMap((part) =>
(part ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean),
),
),
)
if (phrases.length === 0) return null
const merged: string[] = []
let totalLength = 0
for (const phrase of phrases) {
const additionLength = merged.length === 0 ? phrase.length : phrase.length + 2
if (totalLength + additionLength > AI_RENDER_NEGATIVE_PROMPT_MAX_LENGTH) {
break
}
merged.push(phrase)
totalLength += additionLength
}
return merged.length > 0 ? merged.join(', ') : null
}
function getTaskModeLabel(mode: LocalRenderRecord['mode']): string {
if (mode === 'white-background') return '白底图'
if (mode === 'eight-view') return '八视图'
return '场景效果图'
}
export function buildTaskStartedMessage(params: {
mode: LocalRenderRecord['mode']
skuName: string
imageCount: number
}) {
const modeLabel = getTaskModeLabel(params.mode)
return `${modeLabel}已进入后台生成:${params.skuName},共 ${params.imageCount} 张。你可以继续切换 SKU 或编辑参数。`
}
export function waitForNextFrame(): Promise<void> {
return new Promise((resolve) => {
if (typeof window === 'undefined') {
resolve()
return
}
window.requestAnimationFrame(() => resolve())
})
}
export function calculateSidePanelPosition(
menuLeft: number,
menuTop: number,
width: number,
): WorkbenchSidePanelPosition {
if (typeof window === 'undefined') {
return { left: 228, top: 4, maxHeight: 320 }
}
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const viewportGap = 12
const menuGap = 8
const rightSideLeft = 228
const top = 4
const left =
menuLeft + rightSideLeft + width > viewportWidth - viewportGap
? -width - menuGap
: rightSideLeft
const maxHeight = Math.max(
220,
Math.min(380, viewportHeight - menuTop - top - viewportGap),
)
return { left, top, maxHeight }
}
export function pickUvFields(material: AiRenderNodeMaterial | null | undefined): Partial<AiRenderNodeMaterial> {
if (!material) {
return {}
}
return {
textureRepeat: material.textureRepeat,
textureRepeatU: material.textureRepeatU,
textureRepeatV: material.textureRepeatV,
textureRotation: material.textureRotation,
textureSizeMm: material.textureSizeMm,
textureProjectionAxis: material.textureProjectionAxis,
textureAxisConfigs: material.textureAxisConfigs,
}
}
export function compactNodeMaterial(material: AiRenderNodeMaterial): AiRenderNodeMaterial {
return Object.fromEntries(
Object.entries(material).filter(([, value]) => value !== undefined),
) as AiRenderNodeMaterial
}
export function formatMaterialTargetLabel(nodeNames: string[], scope: MaterialPanelScope): string {
if (scope === 'all') return '整个模型'
if (nodeNames.length > 1) return `已选 ${nodeNames.length} 个部件`
return nodeNames[0] ?? '模型部件'
}
export function formatMaterialTargetSubtitle(nodeNames: string[], scope: MaterialPanelScope): string {
if (scope === 'all') return `${nodeNames.length} 个部件`
return nodeNames.length > 1 ? '多选部件' : '模型部件'
}
export function formatMaterialTargetMessageLabel(nodeNames: string[], scope: MaterialPanelScope): string {
if (scope === 'all') return '整个模型'
if (nodeNames.length > 1) return `${nodeNames.length} 个部件`
return nodeNames[0] ?? '模型部件'
}
function getRenderModeFromServerRecord(render: AssistedAssemblyAiRenderDTO): LocalRenderRecord['mode'] {
const params = toRecord(render.params)
if (params.mode === 'white-background') return 'white-background'
if (params.mode === 'eight-view') return 'eight-view'
const workspaceSnapshot = toRecord(render.workspaceSnapshot)
if (workspaceSnapshot.mode === 'white-background') return 'white-background'
if (workspaceSnapshot.mode === 'eight-view') return 'eight-view'
return 'scene'
}
export function mapServerRenderToLocalRecords(
render: AssistedAssemblyAiRenderDTO,
skuFallback?: AiRenderSku | null,
): LocalRenderRecord[] {
const mode = getRenderModeFromServerRecord(render)
const renderParams = toRecord(render.params)
const workspaceSnapshot = toRecord(render.workspaceSnapshot)
const snapshotSku = toRecord(workspaceSnapshot.sku)
const skuId = render.workbenchSkuId ?? (typeof snapshotSku.id === 'string' ? snapshotSku.id : '')
const skuName =
typeof snapshotSku.name === 'string' && snapshotSku.name.trim()
? snapshotSku.name
: skuFallback?.name ?? '未命名 SKU'
const records: Array<LocalRenderRecord | null> = render.resultImages.map((image, imageIndex) => {
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
const nextRecord: LocalRenderRecord = {
id: `${render.id}:${imageIndex}`,
serverRenderId: render.id,
imageIndex,
skuId,
skuName,
mode,
batchId:
typeof renderParams.batchId === 'string'
? renderParams.batchId
: typeof workspaceSnapshot.batchId === 'string'
? workspaceSnapshot.batchId
: undefined,
viewName:
typeof renderParams.viewName === 'string'
? renderParams.viewName
: typeof workspaceSnapshot.viewName === 'string'
? workspaceSnapshot.viewName
: undefined,
viewIndex:
typeof renderParams.viewIndex === 'number'
? renderParams.viewIndex
: typeof workspaceSnapshot.viewIndex === 'number'
? workspaceSnapshot.viewIndex
: undefined,
creatorName: render.creatorName ?? null,
cameraView: toCameraViewSnapshot(workspaceSnapshot.cameraView),
baseCameraView: toCameraViewSnapshot(workspaceSnapshot.baseCameraView),
imageUrl,
storageKey: image.storageKey ?? null,
expiresAt: image.expiresAt ?? null,
temporary: image.temporary,
prompt: render.prompt,
status: render.status === 'FAILED' ? 'FAILED' : 'SUCCEEDED',
createdAt: render.createdAt,
}
return nextRecord
})
return records.filter((record): record is LocalRenderRecord => record !== null)
}
export function resolveWorkbenchSelection(
spus: AiRenderSpu[],
preferredSpuId?: string | null,
preferredSkuId?: string | null,
) {
const selectedSpu = preferredSpuId
? spus.find((spu) => spu.id === preferredSpuId) ?? spus[0] ?? null
: spus[0] ?? null
const selectedSku = selectedSpu
? preferredSkuId
? selectedSpu.skus.find((sku) => sku.id === preferredSkuId) ?? selectedSpu.skus[0] ?? null
: selectedSpu.skus[0] ?? null
: null
return {
selectedSpuId: selectedSpu?.id ?? null,
selectedSkuId: selectedSku?.id ?? null,
}
}

View File

@ -0,0 +1,428 @@
'use client'
import type { MaterialAxisUvConfig } from '@/lib/3d/material-manager'
import type {
AiRenderCameraViewSnapshot,
AiRenderMaterialPartAnnotation,
AiRenderNodeMaterial,
AiRenderSku,
AiRenderSpu,
AiRenderTemporaryReferenceImage,
LocalRenderRecord,
} from './types'
import { createId, getDefaultSkuName, getDefaultSpuName } from './utils'
export const AI_RENDER_WORKBENCH_STORAGE_KEY = 'ai-render-workbench:v1'
export const AI_RENDER_WORKBENCH_UI_STORAGE_KEY = 'ai-render-workbench:ui:v1'
export const DEFAULT_MATERIAL_COLOR = '#d8c6ad'
export const DEFAULT_PROMPT = ''
export const DEFAULT_NEGATIVE_PROMPT = 'deformed product, wrong structure, extra parts, watermark, text'
export const AI_RENDER_DEFAULT_IMAGE_SIZE = '2K'
const LEGACY_DEFAULT_PROMPT =
'modern minimalist interior, product photography, natural daylight, realistic material, clean composition'
const LEGACY_SAMPLE_SPU_ID = 'spu-cj0001'
const LEGACY_SAMPLE_SKU_IDS = new Set(['sku-cj000101', 'sku-cj000102', 'sku-cj000103'])
const LEGACY_DEFAULT_PROMPT_NORMALIZED = normalizePromptForDefaultComparison(LEGACY_DEFAULT_PROMPT)
export type StoredWorkbenchState = {
spus: AiRenderSpu[]
selectedSpuId: string | null
selectedSkuId: string | null
records: LocalRenderRecord[]
prompt: string
negativePrompt: string
aspectRatio: string
imageSize: string
imageCount: number
}
export type StoredWorkbenchUiState = Omit<StoredWorkbenchState, 'spus' | 'records'> & {
detailReferenceImagesBySkuId?: Record<string, AiRenderTemporaryReferenceImage>
materialPartAnnotationsBySkuId?: Record<string, AiRenderMaterialPartAnnotation[]>
}
function normalizePromptForDefaultComparison(value: string): string {
return value.replace(/\s+/g, ' ').trim().toLowerCase()
}
export function stripLegacyDefaultPrompt(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ''
if (normalizePromptForDefaultComparison(trimmed) === LEGACY_DEFAULT_PROMPT_NORMALIZED) {
return ''
}
if (trimmed.toLowerCase().startsWith(LEGACY_DEFAULT_PROMPT.toLowerCase())) {
return trimmed
.slice(LEGACY_DEFAULT_PROMPT.length)
.replace(/^[\s,,.。;;::|/\\-]+/, '')
.trim()
}
return trimmed
}
function normalizeStoredPrompt(value: unknown): string {
return typeof value === 'string' ? stripLegacyDefaultPrompt(value) : DEFAULT_PROMPT
}
export function normalizeWorkbenchImageSize(value: unknown): string {
if (typeof value !== 'string') {
return AI_RENDER_DEFAULT_IMAGE_SIZE
}
const normalized = value.trim().toUpperCase()
if (!normalized) {
return AI_RENDER_DEFAULT_IMAGE_SIZE
}
if (normalized === '4K') {
return AI_RENDER_DEFAULT_IMAGE_SIZE
}
return normalized
}
function normalizeTemporaryReferenceImage(value: unknown): AiRenderTemporaryReferenceImage | undefined {
if (!isRecord(value)) return undefined
const url = typeof value.url === 'string' ? value.url.trim() : ''
const storageKey = typeof value.storageKey === 'string' ? value.storageKey.trim() : ''
if (!url || !storageKey) return undefined
return {
url,
storageKey,
expiresAt:
typeof value.expiresAt === 'string' || value.expiresAt === null
? value.expiresAt
: null,
temporary: value.temporary === true,
}
}
function normalizeDetailReferenceImages(
value: unknown,
): Record<string, AiRenderTemporaryReferenceImage> | undefined {
if (!isRecord(value)) return undefined
const entries = Object.entries(value).reduce<Record<string, AiRenderTemporaryReferenceImage>>(
(result, [skuId, image]) => {
const normalizedImage = normalizeTemporaryReferenceImage(image)
if (skuId.trim() && normalizedImage) {
result[skuId] = normalizedImage
}
return result
},
{},
)
return Object.keys(entries).length > 0 ? entries : undefined
}
function normalizeMaterialPartAnnotations(
value: unknown,
): Record<string, AiRenderMaterialPartAnnotation[]> | undefined {
if (!isRecord(value)) return undefined
const entries = Object.entries(value).reduce<Record<string, AiRenderMaterialPartAnnotation[]>>(
(result, [skuId, annotations]) => {
if (!skuId.trim() || !Array.isArray(annotations)) return result
const normalizedAnnotations = annotations
.map((annotation) => {
if (!isRecord(annotation)) return null
const instruction = typeof annotation.instruction === 'string'
? annotation.instruction.trim()
: ''
const nodeNames = Array.isArray(annotation.nodeNames)
? Array.from(
new Set(
annotation.nodeNames
.map((nodeName) => (typeof nodeName === 'string' ? nodeName.trim() : ''))
.filter(Boolean),
),
)
: []
if (!instruction || nodeNames.length === 0) return null
return {
id: typeof annotation.id === 'string' && annotation.id.trim()
? annotation.id.trim()
: createId('material-part'),
nodeNames,
instruction,
}
})
.filter((annotation): annotation is AiRenderMaterialPartAnnotation => Boolean(annotation))
if (normalizedAnnotations.length > 0) {
result[skuId] = normalizedAnnotations
}
return result
},
{},
)
return Object.keys(entries).length > 0 ? entries : undefined
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function toRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {}
}
export function toCameraViewSnapshot(value: unknown): AiRenderCameraViewSnapshot | undefined {
const record = toRecord(value)
const position = Array.isArray(record.position) ? record.position : null
const target = Array.isArray(record.target) ? record.target : null
if (!position || !target || position.length !== 3 || target.length !== 3) return undefined
if (!position.every((item) => typeof item === 'number' && Number.isFinite(item))) return undefined
if (!target.every((item) => typeof item === 'number' && Number.isFinite(item))) return undefined
return {
position: position as [number, number, number],
target: target as [number, number, number],
}
}
function normalizeNodeMaterials(value: unknown): Record<string, AiRenderNodeMaterial> | undefined {
if (!isRecord(value)) return undefined
const readNumber = (input: unknown) =>
typeof input === 'number' && Number.isFinite(input) ? input : undefined
const readAxis = (input: unknown) =>
input === 'x' || input === 'y' || input === 'z' ? input : undefined
const readAxisConfigs = (input: unknown): AiRenderNodeMaterial['textureAxisConfigs'] | undefined => {
if (!isRecord(input)) return undefined
const axisConfigs = (['x', 'y', 'z'] as const).reduce<NonNullable<AiRenderNodeMaterial['textureAxisConfigs']>>(
(result, axis) => {
const config = input[axis]
if (!isRecord(config)) return result
const axisConfig: MaterialAxisUvConfig = {}
const textureRepeat = readNumber(config.textureRepeat)
const textureRepeatU = readNumber(config.textureRepeatU)
const textureRepeatV = readNumber(config.textureRepeatV)
const textureRotation = readNumber(config.textureRotation)
const textureSizeMm = readNumber(config.textureSizeMm)
if (textureRepeat !== undefined) axisConfig.textureRepeat = textureRepeat
if (textureRepeatU !== undefined) axisConfig.textureRepeatU = textureRepeatU
if (textureRepeatV !== undefined) axisConfig.textureRepeatV = textureRepeatV
if (textureRotation !== undefined) axisConfig.textureRotation = textureRotation
if (textureSizeMm !== undefined) axisConfig.textureSizeMm = textureSizeMm
if (Object.keys(axisConfig).length > 0) {
result[axis] = axisConfig
}
return result
},
{},
)
return Object.keys(axisConfigs).length > 0 ? axisConfigs : undefined
}
const materials = Object.entries(value).reduce<Record<string, AiRenderNodeMaterial>>((result, [nodeName, material]) => {
if (!nodeName || !isRecord(material)) return result
result[nodeName] = {
color: typeof material.color === 'string' && material.color.trim() ? material.color : DEFAULT_MATERIAL_COLOR,
materialId: typeof material.materialId === 'string' ? material.materialId : undefined,
materialName: typeof material.materialName === 'string' ? material.materialName : undefined,
categoryId: typeof material.categoryId === 'string' ? material.categoryId : undefined,
categoryName: typeof material.categoryName === 'string' ? material.categoryName : undefined,
thumbnailUrl: typeof material.thumbnailUrl === 'string' || material.thumbnailUrl === null ? material.thumbnailUrl : undefined,
textureUrl: typeof material.textureUrl === 'string' ? material.textureUrl : undefined,
normalMapUrl: typeof material.normalMapUrl === 'string' || material.normalMapUrl === null ? material.normalMapUrl : undefined,
roughnessMapUrl:
typeof material.roughnessMapUrl === 'string' || material.roughnessMapUrl === null
? material.roughnessMapUrl
: undefined,
metalnessMapUrl:
typeof material.metalnessMapUrl === 'string' || material.metalnessMapUrl === null
? material.metalnessMapUrl
: undefined,
aoMapUrl: typeof material.aoMapUrl === 'string' || material.aoMapUrl === null ? material.aoMapUrl : undefined,
metalness: readNumber(material.metalness),
roughness: readNumber(material.roughness),
opacity: readNumber(material.opacity),
transmission: readNumber(material.transmission),
ior: readNumber(material.ior),
thickness: readNumber(material.thickness),
clearcoat: readNumber(material.clearcoat),
clearcoatRoughness: readNumber(material.clearcoatRoughness),
envMapIntensity: readNumber(material.envMapIntensity),
textureRepeat: readNumber(material.textureRepeat),
textureRepeatU: readNumber(material.textureRepeatU),
textureRepeatV: readNumber(material.textureRepeatV),
textureRotation: readNumber(material.textureRotation),
textureSizeMm: readNumber(material.textureSizeMm),
textureProjectionAxis: readAxis(material.textureProjectionAxis),
textureAxisConfigs: readAxisConfigs(material.textureAxisConfigs),
}
return result
}, {})
return Object.keys(materials).length > 0 ? materials : undefined
}
export function normalizeSku(value: unknown, index: number): AiRenderSku {
const sku = isRecord(value) ? value : {}
const nodeMaterials = normalizeNodeMaterials(sku.nodeMaterials)
return {
id: typeof sku.id === 'string' && sku.id.trim() ? sku.id : createId('sku'),
name: typeof sku.name === 'string' ? sku.name : getDefaultSkuName(index + 1),
modelUrl: typeof sku.modelUrl === 'string' ? sku.modelUrl : '',
thumbnailUrl: typeof sku.thumbnailUrl === 'string' ? sku.thumbnailUrl : null,
note: typeof sku.note === 'string' ? sku.note : undefined,
materialColor:
typeof sku.materialColor === 'string' && sku.materialColor.trim()
? sku.materialColor
: DEFAULT_MATERIAL_COLOR,
materialTextureUrl:
typeof sku.materialTextureUrl === 'string' ? sku.materialTextureUrl : undefined,
nodeMaterials,
}
}
export function normalizeSpu(value: unknown, index: number): AiRenderSpu {
const spu = isRecord(value) ? value : {}
const rawSkus = Array.isArray(spu.skus) ? spu.skus : []
return {
id: typeof spu.id === 'string' && spu.id.trim() ? spu.id : createId('spu'),
name: typeof spu.name === 'string' ? spu.name : getDefaultSpuName(index + 1),
description: typeof spu.description === 'string' ? spu.description : '',
skus: rawSkus.map(normalizeSku),
}
}
export function isLegacySampleOnly(spus: AiRenderSpu[]) {
if (spus.length !== 1 || spus[0]?.id !== LEGACY_SAMPLE_SPU_ID) return false
const skus = spus[0].skus
return (
skus.length === LEGACY_SAMPLE_SKU_IDS.size &&
skus.every(
(sku) =>
LEGACY_SAMPLE_SKU_IDS.has(sku.id) &&
!sku.modelUrl.trim() &&
!sku.materialTextureUrl?.trim() &&
Object.keys(sku.nodeMaterials ?? {}).length === 0,
)
)
}
function normalizeRecords(value: unknown): LocalRenderRecord[] {
if (!Array.isArray(value)) return []
return value
.filter(isRecord)
.map((record): LocalRenderRecord => {
const mode: LocalRenderRecord['mode'] =
record.mode === 'white-background'
? 'white-background'
: record.mode === 'eight-view'
? 'eight-view'
: 'scene'
const status: LocalRenderRecord['status'] =
record.status === 'FAILED' ? 'FAILED' : 'SUCCEEDED'
return {
id: typeof record.id === 'string' && record.id.trim() ? record.id : createId('render'),
skuId: typeof record.skuId === 'string' ? record.skuId : '',
skuName: typeof record.skuName === 'string' ? record.skuName : '未命名 SKU',
mode,
batchId: typeof record.batchId === 'string' ? record.batchId : undefined,
viewName: typeof record.viewName === 'string' ? record.viewName : undefined,
viewIndex: typeof record.viewIndex === 'number' ? record.viewIndex : undefined,
creatorName: typeof record.creatorName === 'string' || record.creatorName === null ? record.creatorName : undefined,
cameraView: toCameraViewSnapshot(record.cameraView),
baseCameraView: toCameraViewSnapshot(record.baseCameraView),
imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : '',
storageKey: typeof record.storageKey === 'string' || record.storageKey === null ? record.storageKey : undefined,
expiresAt: typeof record.expiresAt === 'string' || record.expiresAt === null ? record.expiresAt : undefined,
temporary: typeof record.temporary === 'boolean' ? record.temporary : undefined,
prompt: typeof record.prompt === 'string' ? record.prompt : '',
status,
createdAt: typeof record.createdAt === 'string' ? record.createdAt : new Date().toISOString(),
}
})
.filter((record) => record.imageUrl.trim())
}
export function readStoredWorkbenchState(): StoredWorkbenchState | null {
if (typeof window === 'undefined') return null
try {
const raw = window.localStorage.getItem(AI_RENDER_WORKBENCH_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as unknown
if (!isRecord(parsed)) return null
const spus = Array.isArray(parsed.spus) ? parsed.spus.map(normalizeSpu) : []
const normalizedSpus = isLegacySampleOnly(spus) ? [] : spus
const selectedSpuId =
typeof parsed.selectedSpuId === 'string' &&
normalizedSpus.some((spu) => spu.id === parsed.selectedSpuId)
? parsed.selectedSpuId
: normalizedSpus[0]?.id ?? null
const selectedSpu = normalizedSpus.find((spu) => spu.id === selectedSpuId) ?? null
const selectedSkuId =
typeof parsed.selectedSkuId === 'string' &&
selectedSpu?.skus.some((sku) => sku.id === parsed.selectedSkuId)
? parsed.selectedSkuId
: selectedSpu?.skus[0]?.id ?? null
return {
spus: normalizedSpus,
selectedSpuId,
selectedSkuId,
records: normalizeRecords(parsed.records),
prompt: normalizeStoredPrompt(parsed.prompt),
negativePrompt:
typeof parsed.negativePrompt === 'string' ? parsed.negativePrompt : DEFAULT_NEGATIVE_PROMPT,
aspectRatio: typeof parsed.aspectRatio === 'string' ? parsed.aspectRatio : '1:1',
imageSize: normalizeWorkbenchImageSize(parsed.imageSize),
imageCount:
typeof parsed.imageCount === 'number' && Number.isFinite(parsed.imageCount)
? parsed.imageCount
: 1,
}
} catch {
return null
}
}
export function readStoredWorkbenchUiState(): StoredWorkbenchUiState | null {
if (typeof window === 'undefined') return null
try {
const raw = window.localStorage.getItem(AI_RENDER_WORKBENCH_UI_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as unknown
if (!isRecord(parsed)) return null
return {
selectedSpuId: typeof parsed.selectedSpuId === 'string' ? parsed.selectedSpuId : null,
selectedSkuId: typeof parsed.selectedSkuId === 'string' ? parsed.selectedSkuId : null,
prompt: normalizeStoredPrompt(parsed.prompt),
negativePrompt:
typeof parsed.negativePrompt === 'string' ? parsed.negativePrompt : DEFAULT_NEGATIVE_PROMPT,
aspectRatio: typeof parsed.aspectRatio === 'string' ? parsed.aspectRatio : '1:1',
imageSize: normalizeWorkbenchImageSize(parsed.imageSize),
imageCount:
typeof parsed.imageCount === 'number' && Number.isFinite(parsed.imageCount)
? parsed.imageCount
: 1,
detailReferenceImagesBySkuId: normalizeDetailReferenceImages(parsed.detailReferenceImagesBySkuId),
materialPartAnnotationsBySkuId: normalizeMaterialPartAnnotations(
parsed.materialPartAnnotationsBySkuId,
),
}
} catch {
return null
}
}

View File

@ -6,6 +6,7 @@ import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { assistedAssemblyAiRenderService } from '@/server/services'
const AI_RENDER_PROMPT_MAX_LENGTH = 5000
const AI_RENDER_NEGATIVE_PROMPT_MAX_LENGTH = 1200
const AI_RENDER_STRUCTURE_REFERENCE_IMAGE_MAX_COUNT = 4
const createAiRenderSchema = z.object({
@ -16,7 +17,10 @@ const createAiRenderSchema = z.object({
.max(AI_RENDER_PROMPT_MAX_LENGTH, `提示词不能超过 ${AI_RENDER_PROMPT_MAX_LENGTH} 个字符`),
negativePrompt: z
.string()
.max(800, '反向提示词不能超过 800 个字符')
.max(
AI_RENDER_NEGATIVE_PROMPT_MAX_LENGTH,
`反向提示词不能超过 ${AI_RENDER_NEGATIVE_PROMPT_MAX_LENGTH} 个字符`,
)
.optional()
.nullable(),
imageCount: z.number().int().min(1).max(8).optional().nullable(),
@ -36,6 +40,14 @@ const createAiRenderSchema = z.object({
)
.optional()
.nullable(),
materialPartAnnotations: z
.array(z.object({
nodeNames: z.array(z.string().trim().min(1)).min(1).max(32),
instruction: z.string().trim().min(1, '模型标注说明不能为空').max(120, '模型标注说明不能超过 120 个字符'),
}))
.max(12, '模型标注不能超过 12 条')
.optional()
.nullable(),
workspaceSnapshot: z.unknown(),
assetSnapshot: z.unknown(),
})

View File

@ -298,7 +298,7 @@ export async function GET(request: NextRequest) {
salesBrands: item.formSummary.salesBrands || '',
spu: item.formSummary.spu || item.spu || '',
openingBrand: item.formSummary.openingBrand || '',
creatorLabel: item.originatorDeptName || item.boardKeyType || '',
creatorLabel: item.originatorUserName || '',
overallTargetHours: item.overallTargetHours ?? '',
overallActualHours: item.overallActualHours ?? '',
isOvertime: item.overallTimeoutFlag ? '是' : '否',

View File

@ -19,10 +19,10 @@ export async function POST(request: NextRequest) {
// 验证文件类型
const ext = path.extname(file.name).toLowerCase()
if (!UPLOAD.ALLOWED_MODEL_TYPES.includes(ext as '.gltf' | '.glb' | '.obj' | '.dwg')) {
if (!UPLOAD.ALLOWED_MODEL_TYPES.includes(ext as '.zip' | '.gltf' | '.glb' | '.obj' | '.dwg')) {
return jsonError(
ErrorCode.FILE_TYPE_NOT_ALLOWED,
`不支持的文件格式 "${ext}",仅支持 .gltf、.glb 和 .obj`,
`不支持的文件格式 "${ext}",仅支持 .zip、.gltf、.glb 和 .obj`,
400,
requestId,
)

View File

@ -312,8 +312,14 @@ function printCommonLayout(
lodop.ADD_PRINT_TEXT(203, 474, 141, 25, record.bar3);
setFont(lodop, 15);
} else {
lodop.ADD_PRINT_TEXT(203, 191, 800, options?.barcodeTextHeight ?? 25, record.barcode);
setFont(lodop, options?.barcodeFontSize ?? 25, true);
// Keep the full barcode on a single row and shrink text when needed.
const barcodeTextHeight = options?.barcodeTextHeight ?? 25;
const barcodeFontSize = options?.barcodeFontSize ?? 25;
lodop.ADD_PRINT_TEXT(203, 191, 800, barcodeTextHeight, record.barcode);
setFont(lodop, barcodeFontSize, true);
lodop.SET_PRINT_STYLEA(0, "TextNeatRow", 1);
lodop.SET_PRINT_STYLEA(0, "WordWrap", 0);
lodop.SET_PRINT_STYLEA(0, "TextFit", 1);
}
lodop.ADD_PRINT_TEXT(182, options?.productionBatchLeft ?? 438, 172, 25, `生产批号:${record.printDate}`);
@ -486,7 +492,7 @@ export async function printBarcodeRow(
}
lodop.NEWPAGE();
lodop.SET_PRINT_MODE("PRINT_PAGE_PERCENT", "Full-Page");
// Print using the configured paper size to avoid stretched output.
template.printFunc(record, lodop, { batchNumber: options.batchNumber });
lodop.PREVIEW();
return template.title;
@ -803,7 +809,7 @@ export async function printCertificateRow(
}
lodop.NEWPAGE();
lodop.SET_PRINT_MODE("PRINT_PAGE_PERCENT", "Full-Page");
// Print using the configured paper size to avoid stretched output.
printCertificateLayout(record, lodop, template, options);
lodop.PREVIEW();
return template.title;

View File

@ -0,0 +1,41 @@
import { createEmptyNonIntegratedOpeningReference } from "./core/nonIntegratedStorage";
import { buildNonIntegratedBasicInfoOptions } from "./nonIntegratedBasicInfoOptions";
import { createFormValuesFromProduct } from "./workspaces/basic-info/productFormValueHelpers";
describe("buildNonIntegratedBasicInfoOptions", () => {
it("filters product categories by the selected secondary category", () => {
const formValues = createFormValuesFromProduct(null);
formValues.salesCategory = "架类";
const options = buildNonIntegratedBasicInfoOptions({
designerNameOptions: [],
formValues,
openingReference: createEmptyNonIntegratedOpeningReference(),
operationOwnerNameOptions: [],
planningOwnerNameOptions: [],
purchasingOwnerNameOptions: [],
spaceDesignerNameOptions: [],
});
expect(options.basicInfoProductCategoryOptions).toContain("鞋架");
expect(options.basicInfoProductCategoryOptions).not.toContain("书桌");
});
it("keeps the current product category visible when it is a custom value", () => {
const formValues = createFormValuesFromProduct(null);
formValues.salesCategory = "架类";
formValues.productCategory = "自定义类目";
const options = buildNonIntegratedBasicInfoOptions({
designerNameOptions: [],
formValues,
openingReference: createEmptyNonIntegratedOpeningReference(),
operationOwnerNameOptions: [],
planningOwnerNameOptions: [],
purchasingOwnerNameOptions: [],
spaceDesignerNameOptions: [],
});
expect(options.basicInfoProductCategoryOptions).toContain("自定义类目");
});
});

View File

@ -23,15 +23,16 @@ export function buildNonIntegratedBasicInfoOptions({
purchasingOwnerNameOptions: string[];
spaceDesignerNameOptions: string[];
}) {
const effectiveSecondaryCategory =
formValues.salesCategory.trim() || openingReference.salesCategory.trim();
return {
basicInfoSecondaryCategoryOptions: getUniqueNonEmptyStrings([
...NON_INTEGRATED_SECONDARY_CATEGORY_OPTIONS,
formValues.salesCategory,
]),
basicInfoProductCategoryOptions: getUniqueNonEmptyStrings([
...getNonIntegratedProductCategoryOptions(formValues.salesCategory),
...getNonIntegratedProductCategoryOptions(openingReference.salesCategory),
...getNonIntegratedProductCategoryOptions(""),
...getNonIntegratedProductCategoryOptions(effectiveSecondaryCategory),
formValues.productCategory,
openingReference.productCategory,
]),

View File

@ -0,0 +1,66 @@
import type { MutableRefObject } from "react";
import { createEmptyNonIntegratedOpeningReference } from "../../core/nonIntegratedStorage";
import { createFormValuesFromProduct } from "./productFormValueHelpers";
import { useBasicInfoFieldController } from "./useBasicInfoFieldController";
describe("useBasicInfoFieldController", () => {
it("clears an incompatible product category when sales category changes", () => {
let formValues = createFormValuesFromProduct(null);
formValues = {
...formValues,
salesCategory: "桌类",
productCategory: "书桌",
};
let openingReference = {
...createEmptyNonIntegratedOpeningReference(),
salesCategory: "桌类",
productCategory: "书桌",
};
const formValuesRef = {
current: formValues,
} as MutableRefObject<typeof formValues>;
const openingReferenceRef = {
current: openingReference,
} as MutableRefObject<typeof openingReference>;
const scheduleAutoSave = jest.fn();
const controller = useBasicInfoFieldController({
canUseExistingSpuCreateMode: false,
findExistingSpuCreateOptionByValue: () => null,
formValues,
formValuesRef,
isLockedCategoryFormField: () => false,
isManualSeriesProductSpu: false,
isReadOnly: false,
isStockProductCreate: false,
mode: "create",
openingReferenceRef,
scheduleAutoSave,
setFormValuesAndRef: (updater) => {
formValues = updater(formValues);
formValuesRef.current = formValues;
},
setOpeningReferenceAndRef: (updater) => {
openingReference = updater(openingReference);
openingReferenceRef.current = openingReference;
},
setSaveMessage: jest.fn(),
setSaveMessageType: jest.fn(),
setSelectedExistingSpu: jest.fn(),
setSkuItems: jest.fn(),
setSpuCreateMode: jest.fn(),
setStockProductSpu: jest.fn(),
});
controller.updateField("salesCategory", "架类");
expect(formValuesRef.current.salesCategory).toBe("架类");
expect(formValuesRef.current.productCategory).toBe("");
expect(openingReferenceRef.current.salesCategory).toBe("架类");
expect(openingReferenceRef.current.productCategory).toBe("");
expect(scheduleAutoSave).toHaveBeenCalledTimes(1);
});
});

View File

@ -12,6 +12,7 @@ import {
type NonIntegratedOpeningReference,
type NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import { normalizeNonIntegratedProductCategoryForSecondaryCategory } from "@/lib/non-integrated-category-options";
import type {
ExistingSpuCreateOption,
SpuCreateMode,
@ -164,6 +165,31 @@ export function useBasicInfoFieldController({
return;
}
if (key === "salesCategory") {
const nextSalesCategory = value;
setFormValuesAndRef((current) => ({
...current,
salesCategory: nextSalesCategory,
productCategory: normalizeNonIntegratedProductCategoryForSecondaryCategory(
nextSalesCategory,
current.productCategory,
),
}));
setOpeningReferenceAndRef((current) => ({
...current,
salesCategory: nextSalesCategory,
productCategory: normalizeNonIntegratedProductCategoryForSecondaryCategory(
nextSalesCategory,
current.productCategory,
),
}));
setSaveMessage("");
setSaveMessageType("success");
scheduleAutoSave();
return;
}
setFormValuesAndRef((current) => ({ ...current, [key]: value }));
syncBasicInfoFieldToOpeningReference(key, value);
setSaveMessage("");

View File

@ -0,0 +1,21 @@
import { createEmptyNonIntegratedOpeningReference } from "../../core/nonIntegratedStorage";
import { BRAND_OPTIONS } from "../basic-info/basicInfoConfig";
import { updateOpeningReferenceFieldValue } from "./openingReferenceHelpers";
describe("updateOpeningReferenceFieldValue", () => {
it("clears an incompatible product category when sales category changes", () => {
const nextValue = updateOpeningReferenceFieldValue({
brandOptions: BRAND_OPTIONS,
current: {
...createEmptyNonIntegratedOpeningReference(),
salesCategory: "桌类",
productCategory: "书桌",
},
field: "salesCategory",
value: "架类",
});
expect(nextValue.salesCategory).toBe("架类");
expect(nextValue.productCategory).toBe("");
});
});

View File

@ -14,6 +14,7 @@ import {
type NonIntegratedProjectReview,
type NonIntegratedProjectStatus,
} from "../../nonIntegratedStorage";
import { normalizeNonIntegratedProductCategoryForSecondaryCategory } from "@/lib/non-integrated-category-options";
export type ProjectReviewFieldKey = Exclude<
keyof NonIntegratedProjectReview,
@ -152,6 +153,17 @@ export function updateOpeningReferenceFieldValue({
field: OpeningReferenceFieldKey;
value: string;
}) {
if (field === "salesCategory") {
return {
...current,
salesCategory: value,
productCategory: normalizeNonIntegratedProductCategoryForSecondaryCategory(
value,
current.productCategory,
),
};
}
if (field !== "brand") {
return {
...current,

View File

@ -13,6 +13,7 @@ import {
type NonIntegratedOpeningReference,
type NonIntegratedSkuItem,
} from "../../nonIntegratedStorage";
import { normalizeNonIntegratedProductCategoryForSecondaryCategory } from "@/lib/non-integrated-category-options";
import type { NonIntegratedUploadProgressHandler } from "../../nonIntegratedApi";
import { BRAND_OPTIONS } from "../basic-info/basicInfoConfig";
import type { FormValues } from "../basic-info/productFormValueHelpers";
@ -82,6 +83,18 @@ export function useOpeningReferenceController({
field: OpeningReferenceFieldKey,
value: string,
) {
if (field === "salesCategory") {
setFormValuesAndRef((current) => ({
...current,
salesCategory: value,
productCategory: normalizeNonIntegratedProductCategoryForSecondaryCategory(
value,
current.productCategory,
),
}));
return;
}
const mappedField = getOpeningReferenceBasicInfoFieldKey(field);
if (field === "brand") {

View File

@ -274,8 +274,7 @@ function readMetricValue(
}
function readCreatorDisplay(item: OaBoardProjectListItemDTO) {
const initiator = item.nodeAssessments.find((node) => node.nodeName.includes('发起'))?.ownerLabel
return initiator || item.originatorDeptName || item.boardKeyType || '-'
return item.originatorUserName || '-'
}
function buildTimingDetailFields(item: OaBoardProjectListItemDTO): TimingDetailField[] {
@ -343,12 +342,12 @@ function FilterMultiSelect({
return (
<label className="flex flex-col gap-2">
<span className="text-[12px] font-apple-medium text-apple-label-secondary">{label}</span>
<details className="group relative">
<details className="group">
<summary className="flex h-11 cursor-pointer list-none items-center justify-between rounded-apple-lg border border-black/8 bg-white/80 px-3.5 text-sm text-apple-label-primary outline-none transition marker:hidden group-open:border-[color:var(--color-blue)] group-open:bg-white">
<span className={values.length === 0 ? 'text-apple-label-tertiary' : ''}>{summaryLabel}</span>
<span className="text-[11px] text-apple-label-secondary">{values.length > 0 ? `${values.length} 项` : '多选'}</span>
</summary>
<div className="absolute z-40 mt-2 w-full min-w-[220px] rounded-apple-xl border border-black/8 bg-white p-2 shadow-[0_24px_60px_rgba(15,23,42,0.12)]">
<div className="mt-2 w-full min-w-[220px] rounded-apple-xl border border-black/8 bg-white p-2 shadow-[0_24px_60px_rgba(15,23,42,0.12)]">
<div className="mb-2 flex items-center justify-between px-2 pt-1">
<span className="text-[11px] text-apple-label-secondary">可多选</span>
<button

View File

@ -160,3 +160,25 @@ export function getNonIntegratedProductCategoryOptions(
source.map((option) => option.productCategory),
);
}
export function normalizeNonIntegratedProductCategoryForSecondaryCategory(
secondaryCategory: string,
productCategory: string,
) {
const normalizedSecondaryCategory = secondaryCategory.trim();
const normalizedProductCategory = productCategory.trim();
if (!normalizedProductCategory) {
return "";
}
if (!normalizedSecondaryCategory) {
return normalizedProductCategory;
}
return getNonIntegratedProductCategoryOptions(
normalizedSecondaryCategory,
).includes(normalizedProductCategory)
? normalizedProductCategory
: "";
}

View File

@ -938,6 +938,10 @@ export interface CreateAssistedAssemblyAiRenderPayload {
workbenchSkuId?: string | null
referenceImageDataUrl: string
structureReferenceImageDataUrls?: string[] | null
materialPartAnnotations?: Array<{
nodeNames: string[]
instruction: string
}> | null
workspaceSnapshot: unknown
assetSnapshot: unknown
}

View File

@ -66,7 +66,7 @@ export const UPLOAD = {
/** 允许的图片类型 */
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
/** 允许的模型类型 */
ALLOWED_MODEL_TYPES: ['.gltf', '.glb', '.obj', '.dwg'],
ALLOWED_MODEL_TYPES: ['.zip', '.gltf', '.glb', '.obj', '.dwg'],
/** 缩略图尺寸 */
THUMBNAIL_SIZE: { width: 300, height: 300 },
} as const

View File

@ -433,7 +433,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
})
})
it('forwards material reference images for white-background workbench renders', async () => {
it('keeps white-background workbench renders on prompt-only material guidance without forwarding material references', async () => {
const textureUrl =
'https://qnycdn.example.com/prod-assets/materials/textures/material-library/oak.webp?e=old&token=a'
const fetchMock = jest.fn()
@ -474,10 +474,7 @@ describe('assistedAssemblyAiRenderService.create', () => {
{ id: 'user-1' } as never,
)
expect(mockCreateObjectReadUrl).toHaveBeenCalledTimes(1)
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith(
'prod-assets/materials/textures/material-library/oak.webp',
)
expect(mockCreateObjectReadUrl).not.toHaveBeenCalled()
expect(fetchMock).not.toHaveBeenCalledWith(
expect.stringContaining('materials/textures/material-library/oak.webp'),
expect.anything(),
@ -489,10 +486,10 @@ describe('assistedAssemblyAiRenderService.create', () => {
additionalReferenceImages?: unknown[]
}
expect(externalInput.prompt).toContain('Material mapping for this product')
expect(externalInput.prompt).toContain('[Reference #2]')
expect(externalInput.referenceImageUrls).toEqual([
'https://qnycdn.example.com/prod-assets/materials/textures/material-library/oak.webp?e=fresh&token=fresh',
])
expect(externalInput.prompt).toContain('橡木')
expect(externalInput.prompt).toContain('桌面')
expect(externalInput.prompt).not.toContain('[Reference #2]')
expect(externalInput.referenceImageUrls).toBeUndefined()
expect(externalInput.additionalReferenceImages).toBeUndefined()
const createArgs = mockAssistedAssemblyAiRenderCreate.mock.calls.at(-1)?.[0] as {
@ -505,9 +502,9 @@ describe('assistedAssemblyAiRenderService.create', () => {
imageSize: '2K',
materialReferences: {
extracted: 1,
urlReferenced: 1,
urlReferenced: 0,
fileUploaded: 0,
deliveryMode: 'signed-url',
deliveryMode: 'disabled',
},
})
})

View File

@ -26,7 +26,7 @@ import {
const DEFAULT_IMAGE_COUNT = 1
const MAX_IMAGE_COUNT = 8
const MAX_PROMPT_LENGTH = 5000
const MAX_NEGATIVE_PROMPT_LENGTH = 800
const MAX_NEGATIVE_PROMPT_LENGTH = 1200
const MAX_STRUCTURE_REFERENCE_IMAGE_COUNT = 4
const DATA_URL_IMAGE_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/i
const MAX_MATERIAL_TEXTURES = 6
@ -724,6 +724,10 @@ export interface CreateAssistedAssemblyAiRenderInput {
workbenchSkuId?: string | null
referenceImageDataUrl: string
structureReferenceImageDataUrls?: string[] | null
materialPartAnnotations?: Array<{
nodeNames: string[]
instruction: string
}> | null
workspaceSnapshot: unknown
assetSnapshot: unknown
}
@ -860,6 +864,10 @@ function buildAiRenderParams(params: {
expiresAt?: string | null
temporary?: boolean
}>
materialPartAnnotations?: Array<{
nodeNames: string[]
instruction: string
}>
}): Prisma.InputJsonValue {
return {
imageCount: params.imageCount,
@ -872,6 +880,9 @@ function buildAiRenderParams(params: {
...(params.structureReferenceImages && params.structureReferenceImages.length > 0
? { structureReferenceImages: params.structureReferenceImages }
: {}),
...(params.materialPartAnnotations && params.materialPartAnnotations.length > 0
? { materialPartAnnotations: params.materialPartAnnotations }
: {}),
materialReferences: {
extracted: params.materialReferences.extracted,
urlReferenced: params.materialReferences.urlReferenced,
@ -916,6 +927,39 @@ function decodeReferenceImageDataUrls(value: unknown): Array<{ buffer: Buffer; c
.map((item) => decodeReferenceImageDataUrl(item))
}
function normalizeMaterialPartAnnotations(value: unknown): Array<{
nodeNames: string[]
instruction: string
}> {
if (!Array.isArray(value)) return []
return value
.slice(0, 12)
.map((item) => {
const record = toRecord(item)
const nodeNames = Array.isArray(record.nodeNames)
? Array.from(
new Set(
record.nodeNames
.map((nodeName) => (typeof nodeName === 'string' ? nodeName.trim() : ''))
.filter(Boolean),
),
).slice(0, 32)
: []
if (nodeNames.length === 0) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少模型标注部件')
}
const instruction = trimToNull(typeof record.instruction === 'string' ? record.instruction : null)
if (!instruction) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少模型标注说明')
}
return {
nodeNames,
instruction,
}
})
}
function readImageStorageKey(value: string | null | undefined): string | null {
if (!value) return null
const localStorageKey = readLocalWorkbenchRenderStorageKey(value)
@ -2272,6 +2316,9 @@ export class AssistedAssemblyAiRenderService {
const structureReferenceImages = workbenchSku
? decodeReferenceImageDataUrls(input.structureReferenceImageDataUrls)
: []
const materialPartAnnotations = workbenchSku
? normalizeMaterialPartAnnotations(input.materialPartAnnotations)
: []
const materialEntries = extractEnhancedMaterialEntriesFromSnapshot(input.assetSnapshot)
const materialReferenceStats: {
extracted: number
@ -2322,7 +2369,6 @@ export class AssistedAssemblyAiRenderService {
temporary: true,
})
}
const buildCurrentParams = () => buildAiRenderParams({
imageCount,
aspectRatio,
@ -2335,6 +2381,9 @@ export class AssistedAssemblyAiRenderService {
...(structureReferenceUploads.length > 0
? { structureReferenceImages: structureReferenceUploads }
: {}),
...(materialPartAnnotations.length > 0
? { materialPartAnnotations }
: {}),
})
const baseCreateData = {
@ -2460,6 +2509,9 @@ export class AssistedAssemblyAiRenderService {
...(structureReferenceUploads.length > 0
? { structureReferenceImages: structureReferenceUploads }
: {}),
...(materialPartAnnotations.length > 0
? { materialPartAnnotations }
: {}),
}),
referenceImageUrl: referenceUpload.url,
resultImages: [] as unknown as Prisma.InputJsonValue,

View File

@ -6,6 +6,10 @@ const DEFAULT_REDIRECT_PATH = '/'
const STATIC_REDIRECT_PATHS = new Set([
'/',
'/intake',
'/draft-groups',
'/asset-center',
'/ai-render-workbench',
'/products',
'/products/new',
'/users',

View File

@ -554,19 +554,21 @@ function joinUnique(values: Array<string | null | undefined>) {
return items.join(';')
}
async function resolveActiveApproverNameLookup(groups: PreparedProjectGroup[]) {
async function resolveUserNameLookup(groups: PreparedProjectGroup[]) {
const userIds = Array.from(
new Set(
groups
.flatMap((group) => pickActiveTasks(group.flowRecords))
.map((taskRecord) => normalizeText(taskRecord.userId))
.flatMap((group) => [
...pickActiveTasks(group.flowRecords).map((taskRecord) => normalizeText(taskRecord.userId)),
...group.flowRecords.map((record) => normalizeText(record.originatorUserId)),
])
.filter((value): value is string => Boolean(value)),
),
)
const approverNameLookup = new Map<string, string>()
const userNameLookup = new Map<string, string>()
if (userIds.length === 0) {
return approverNameLookup
return userNameLookup
}
const [users, employees] = await Promise.all([
@ -595,19 +597,19 @@ async function resolveActiveApproverNameLookup(groups: PreparedProjectGroup[]) {
const key = normalizeText(user.dingtalkUserId)
const name = normalizeText(user.name)
if (key && name) {
approverNameLookup.set(key, name)
userNameLookup.set(key, name)
}
})
employees.forEach((employee) => {
const key = normalizeText(employee.dingtalkUserId)
const name = normalizeText(employee.name)
if (key && name && !approverNameLookup.has(key)) {
approverNameLookup.set(key, name)
if (key && name && !userNameLookup.has(key)) {
userNameLookup.set(key, name)
}
})
const unresolvedUserIds = userIds.filter((userId) => !approverNameLookup.has(userId))
const unresolvedUserIds = userIds.filter((userId) => !userNameLookup.has(userId))
const externalResults = await Promise.allSettled(
unresolvedUserIds.map(async (userId) => ({
userId,
@ -625,12 +627,12 @@ async function resolveActiveApproverNameLookup(groups: PreparedProjectGroup[]) {
? normalizeText(result.value.employee.name)
: null
if (key && employeeName && !approverNameLookup.has(key)) {
approverNameLookup.set(key, employeeName)
if (key && employeeName && !userNameLookup.has(key)) {
userNameLookup.set(key, employeeName)
}
})
return approverNameLookup
return userNameLookup
}
function buildFieldSnapshots(rows: SourceSchemeRow[]): NormalizedFieldSnapshot[] {
@ -657,9 +659,29 @@ function buildFieldSnapshots(rows: SourceSchemeRow[]): NormalizedFieldSnapshot[]
function getSourceTaskRowSortValue(row: SourceTaskRow) {
const finishedAt = parseChinaDateTime(row.finishTime)?.getTime() ?? 0
const createdAt = parseChinaDateTime(row.createTime)?.getTime() ?? 0
const normalizedStatus = normalizeText(row.status)?.toUpperCase() ?? ''
const normalizedResult = normalizeText(row.result)?.toUpperCase() ?? ''
const statusPriority =
OA_BOARD_RUNNING_STATUSES.has(normalizedStatus)
? 3
: normalizedStatus === 'COMPLETED'
? 2
: normalizedStatus === 'CANCELED' || normalizedStatus === 'TERMINATED'
? 1
: 0
const resultPriority =
normalizedResult === 'REDIRECTED'
? 2
: normalizedResult === 'NONE'
? 1
: normalizedResult === 'REFUSE'
? 0
: 1
return {
eventTime: Math.max(finishedAt, createdAt),
hasFinishedAt: finishedAt > 0 ? 1 : 0,
createdAt,
statusPriority,
resultPriority,
rowId: Number(row.id) || 0,
}
}
@ -671,8 +693,14 @@ function isNewerSourceTaskRow(nextRow: SourceTaskRow, currentRow: SourceTaskRow)
if (nextValue.eventTime !== currentValue.eventTime) {
return nextValue.eventTime > currentValue.eventTime
}
if (nextValue.hasFinishedAt !== currentValue.hasFinishedAt) {
return nextValue.hasFinishedAt > currentValue.hasFinishedAt
if (nextValue.createdAt !== currentValue.createdAt) {
return nextValue.createdAt > currentValue.createdAt
}
if (nextValue.statusPriority !== currentValue.statusPriority) {
return nextValue.statusPriority > currentValue.statusPriority
}
if (nextValue.resultPriority !== currentValue.resultPriority) {
return nextValue.resultPriority > currentValue.resultPriority
}
return nextValue.rowId > currentValue.rowId
}
@ -1402,7 +1430,7 @@ function buildTimelineMetrics(
function buildProjectGraph(
group: PreparedProjectGroup,
approverNameLookup: Map<string, string>,
userNameLookup: Map<string, string>,
): OaBoardProjectGraphInput {
const records = [...group.flowRecords].sort((left, right) => {
const leftPriority = OA_BOARD_FLOW_PRIORITY.indexOf(left.flowType)
@ -1460,7 +1488,7 @@ function buildProjectGraph(
currentOwnerDisplay: joinUnique(
activeTasks.map((taskRecord) => {
const userId = normalizeText(taskRecord.userId)
const approverName = userId ? approverNameLookup.get(userId) ?? null : null
const approverName = userId ? userNameLookup.get(userId) ?? null : null
return approverName || taskRecord.ownerLabel || null
}),
) || '-',
@ -1514,6 +1542,10 @@ function buildProjectGraph(
pricingCode: pricingRecord?.pricingCode ?? null,
sourceBusinessId: reviewRecord?.businessId ?? null,
originatorUserId: reviewRecord?.originatorUserId ?? null,
originatorUserName:
reviewRecord?.originatorUserId
? userNameLookup.get(reviewRecord.originatorUserId) ?? null
: null,
originatorDeptId: reviewRecord?.originatorDeptId ?? null,
originatorDeptName: reviewRecord?.originatorDeptName ?? null,
currentStageKey: runningTask?.timelineStageKey ?? currentFlow?.flowType ?? null,
@ -1901,8 +1933,8 @@ export async function queryOaBoardPreview(limit?: number): Promise<OaBoardPrevie
: undefined
const sourceRecords = await fetchSourceData(normalizedLimit)
const projectGroups = buildProjectGroups(sourceRecords)
const approverNameLookup = await resolveActiveApproverNameLookup(projectGroups)
const projectGraphs = projectGroups.map((group) => buildProjectGraph(group, approverNameLookup))
const userNameLookup = await resolveUserNameLookup(projectGroups)
const projectGraphs = projectGroups.map((group) => buildProjectGraph(group, userNameLookup))
const counts = countNestedRecords(projectGraphs)
return {

View File

@ -96,6 +96,7 @@ export interface OaBoardProjectListItemDTO {
overallTimeoutFlag: boolean
createdDate: string | null
finishedDate: string | null
originatorUserName: string | null
originatorDeptName: string | null
lastSourceUpdatedAt: string | null
styleImages: OaBoardFormPreviewImageDTO[]
@ -258,6 +259,7 @@ export interface OaBoardProjectGraphInput {
pricingCode: string | null
sourceBusinessId: string | null
originatorUserId: string | null
originatorUserName: string | null
originatorDeptId: string | null
originatorDeptName: string | null
currentStageKey: string | null
@ -376,6 +378,7 @@ function buildMockOaBoardItem(input: {
overallTimeoutFlag: timeoutHours > 0,
createdDate: input.createdDate,
finishedDate: input.finishedDate,
originatorUserName: '系统管理员',
originatorDeptName: '产品开发中心',
lastSourceUpdatedAt: new Date().toISOString(),
styleImages: [],
@ -840,6 +843,7 @@ function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItem
overallTimeoutFlag: record.overallTimeoutFlag,
createdDate: toIsoString(reviewFlow?.createdAtSource ?? record.flowInstances[0]?.createdAtSource),
finishedDate: toIsoString(reviewFlow?.finishedAtSource ?? record.flowInstances[0]?.finishedAtSource),
originatorUserName: record.originatorUserName,
originatorDeptName: record.originatorDeptName,
lastSourceUpdatedAt: toIsoString(record.lastSourceUpdatedAt),
styleImages,