Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/wuyongjie-ai-white-background-color-fix
This commit is contained in:
commit
98e89f1573
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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', '重生成失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
}))
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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', '生成失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
? {
|
||||
|
||||
@ -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', '生成失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
})
|
||||
|
||||
@ -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 ? '是' : '否',
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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("自定义类目");
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
]),
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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("");
|
||||
|
||||
@ -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("");
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
: "";
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user