From c8d3d3d9e794bd0aaae2b0c8d2ade43c52dc8202 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 16 Apr 2026 23:13:16 +0800 Subject: [PATCH] feat: restore material library and assembly workflows --- ...assisted-assembly-legacy-feedback.test.tsx | 25 +- .../legacy-assembly-entrypoints.test.tsx | 158 ++++++- .../MaterialLibraryClient.tsx | 445 +++++++++++++----- .../src/app/assisted-assembly/page.tsx | 2 +- .../src/app/products/[id]/assembly/page.tsx | 246 +++++++++- .../src/components/3d/ModelViewer.tsx | 60 ++- .../3d/__tests__/ModelViewer.reload.test.tsx | 26 + .../3d/__tests__/ModelViewer.test.tsx | 76 +++ .../platform/AssemblyWorkbenchV2.tsx | 55 ++- .../__tests__/AssemblyWorkbenchV2.test.tsx | 97 ++++ .../lib/open-product/assembly-workspace.ts | 21 +- .../manual-assembly-launch.test.ts | 43 +- .../open-product/manual-assembly-launch.ts | 74 ++- .../asset-cad-import.service.test.ts | 39 ++ .../__tests__/asset-center.service.test.ts | 163 +++++++ .../__tests__/draft-group.mapper.test.ts | 158 +++++++ .../services/asset-cad-import.service.ts | 6 + .../server/services/asset-center.service.ts | 113 ++++- 18 files changed, 1637 insertions(+), 170 deletions(-) create mode 100644 furniture-3d-system/src/server/services/__tests__/draft-group.mapper.test.ts diff --git a/furniture-3d-system/src/app/__tests__/assisted-assembly-legacy-feedback.test.tsx b/furniture-3d-system/src/app/__tests__/assisted-assembly-legacy-feedback.test.tsx index b5d1d452..0a4f7f69 100644 --- a/furniture-3d-system/src/app/__tests__/assisted-assembly-legacy-feedback.test.tsx +++ b/furniture-3d-system/src/app/__tests__/assisted-assembly-legacy-feedback.test.tsx @@ -57,7 +57,7 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({ })) describe('AssistedAssemblyPage legacy feedback', () => { - it('会给旧产品装配入口的跳转提供最小必要反馈', async () => { + it('会给旧产品装配入口的多 source-sku 冲突提供更明确反馈', async () => { render( await AssistedAssemblyPage({ searchParams: Promise.resolve({ @@ -65,13 +65,34 @@ describe('AssistedAssemblyPage legacy feedback', () => { source: 'assisted-assembly', sourceProductId: 'product-1', redirectedFrom: 'product-assembly', + productRecovery: 'ambiguous-source-sku', + recoverableSourceSkuCount: '2', }), }), ) expect(screen.getByTestId('redirected-from')).toHaveTextContent('product-assembly') expect(screen.getByTestId('feedback-text')).toHaveTextContent( - '旧产品装配入口已下线,请改用辅助装配继续处理。', + '当前产品下找到 2 个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。', + ) + }) + + it('会给旧产品装配入口缺失恢复上下文的情况提供更明确反馈', async () => { + render( + await AssistedAssemblyPage({ + searchParams: Promise.resolve({ + entryMode: 'direct', + source: 'assisted-assembly', + sourceProductId: 'product-1', + redirectedFrom: 'product-assembly', + productRecovery: 'missing-source-context', + }), + }), + ) + + expect(screen.getByTestId('redirected-from')).toHaveTextContent('product-assembly') + expect(screen.getByTestId('feedback-text')).toHaveTextContent( + '当前产品还没有可恢复的源 SKU 或装配快照,已打开普通辅助装配入口。', ) }) }) diff --git a/furniture-3d-system/src/app/__tests__/legacy-assembly-entrypoints.test.tsx b/furniture-3d-system/src/app/__tests__/legacy-assembly-entrypoints.test.tsx index 836d4a37..e44082d9 100644 --- a/furniture-3d-system/src/app/__tests__/legacy-assembly-entrypoints.test.tsx +++ b/furniture-3d-system/src/app/__tests__/legacy-assembly-entrypoints.test.tsx @@ -69,6 +69,30 @@ describe('legacy assembly entrypoints', () => { { id: 'sku-source-1', productId: 'product-1', + isActive: true, + sourcePreSku: { + previewNodes: [], + variantSelection: { + slots: [ + { + slotKey: 'desktop-1', + role: 'desktop', + assetFamilyId: 'family-board', + assetType: 'BOARD', + variantId: 'variant-board', + }, + ], + hardwareSelections: [ + { + selectionKey: 'connector-1', + assetFamilyId: 'family-hardware', + assetType: 'HARDWARE', + variantId: 'variant-hardware', + }, + ], + }, + displaySummary: null, + }, }, ], }) @@ -78,11 +102,139 @@ describe('legacy assembly entrypoints', () => { params: Promise.resolve({ id: 'product-1' }), }), ).rejects.toThrow( - 'NEXT_REDIRECT:/assisted-assembly?entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&sourceProductId=product-1&redirectedFrom=product-assembly', + 'NEXT_REDIRECT:/assisted-assembly?entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&sourceProductId=product-1&redirectedFrom=product-assembly&desktopFamilyIds=family-board&connectorFamilyIds=family-hardware', ) }) - it('产品级旧装配页在没有可恢复 source-sku 时,会退回普通辅助装配入口', async () => { + it('产品级旧装配页在有多个可恢复 source-sku 时,不会盲选,而是带着产品级上下文退回辅助装配', async () => { + mockProductFindUnique.mockResolvedValue({ + id: 'product-1', + createdBy: 'user-1', + departmentId: null, + visibility: 'TEAM', + deletedAt: null, + collaboratorUsers: [], + creator: null, + skus: [ + { + id: 'sku-source-1', + productId: 'product-1', + isActive: false, + sourcePreSku: { + previewNodes: [], + variantSelection: { + slots: [ + { + slotKey: 'desktop-1', + role: 'desktop', + assetFamilyId: 'family-board', + assetType: 'BOARD', + variantId: 'variant-board', + }, + ], + hardwareSelections: [], + }, + displaySummary: null, + }, + }, + { + id: 'sku-source-2', + productId: 'product-1', + isActive: false, + sourcePreSku: { + previewNodes: [], + variantSelection: { + slots: [ + { + slotKey: 'leg-1', + role: 'leg', + assetFamilyId: 'family-leg', + assetType: 'COMPONENT', + variantId: 'variant-leg', + }, + ], + hardwareSelections: [], + }, + displaySummary: null, + }, + }, + ], + }) + + await expect( + ProductAssemblyPage({ + params: Promise.resolve({ id: 'product-1' }), + }), + ).rejects.toThrow( + 'NEXT_REDIRECT:/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=ambiguous-source-sku&recoverableSourceSkuCount=2&desktopFamilyIds=family-board&legFamilyIds=family-leg', + ) + }) + + it('产品级旧装配页在多个候选里只有一个活跃 source-sku 时,会优先恢复这个活跃候选', async () => { + mockProductFindUnique.mockResolvedValue({ + id: 'product-1', + createdBy: 'user-1', + departmentId: null, + visibility: 'TEAM', + deletedAt: null, + collaboratorUsers: [], + creator: null, + skus: [ + { + id: 'sku-source-1', + productId: 'product-1', + isActive: true, + sourcePreSku: { + previewNodes: [], + variantSelection: { + slots: [ + { + slotKey: 'desktop-1', + role: 'desktop', + assetFamilyId: 'family-board', + assetType: 'BOARD', + variantId: 'variant-board', + }, + ], + hardwareSelections: [], + }, + displaySummary: null, + }, + }, + { + id: 'sku-source-2', + productId: 'product-1', + isActive: false, + sourcePreSku: { + previewNodes: [], + variantSelection: { + slots: [ + { + slotKey: 'leg-1', + role: 'leg', + assetFamilyId: 'family-leg', + assetType: 'COMPONENT', + variantId: 'variant-leg', + }, + ], + hardwareSelections: [], + }, + displaySummary: null, + }, + }, + ], + }) + + await expect( + ProductAssemblyPage({ + params: Promise.resolve({ id: 'product-1' }), + }), + ).rejects.toThrow( + 'NEXT_REDIRECT:/assisted-assembly?entryMode=source-sku&source=assisted-assembly&sourceSkuId=sku-source-1&sourceProductId=product-1&redirectedFrom=product-assembly&desktopFamilyIds=family-board', + ) + }) + + it('产品级旧装配页在没有可恢复 source-sku 时,会给辅助装配更明确的产品级恢复反馈', async () => { mockProductFindUnique.mockResolvedValue({ id: 'product-1', createdBy: 'user-1', @@ -99,7 +251,7 @@ describe('legacy assembly entrypoints', () => { params: Promise.resolve({ id: 'product-1' }), }), ).rejects.toThrow( - 'NEXT_REDIRECT:/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly', + 'NEXT_REDIRECT:/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=missing-source-context', ) }) diff --git a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx index 034f248c..b3368e11 100644 --- a/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx +++ b/furniture-3d-system/src/app/asset-center/material-library/MaterialLibraryClient.tsx @@ -1,6 +1,6 @@ 'use client' -import { startTransition, useMemo, useRef, useState, type ReactNode } from 'react' +import { startTransition, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' import { ArrowLeft, Check, @@ -269,6 +269,106 @@ function materialPreviewUrl(material: MaterialDTO): string | null { return material.thumbnailUrl ?? material.textureUrl ?? material.previewUrl ?? null } +function FinishPreviewHeader({ + form, + categoryName, + isEdit = false, +}: { + form: FinishFormState + categoryName: string + isEdit?: boolean +}) { + const displayColor = form.color || '#cccccc' + + const sphereSurfaceStyle = useMemo(() => ({ + backgroundColor: displayColor, + backgroundImage: form.texturePreview + ? `url(${form.texturePreview})` + : form.textureUrl + ? `url(${form.textureUrl})` + : form.thumbnailPreview + ? `url(${form.thumbnailPreview})` + : 'none', + backgroundSize: 'cover', + backgroundPosition: 'center', + }), [displayColor, form.texturePreview, form.textureUrl, form.thumbnailPreview]) + + const sphereHighlightStyle = useMemo(() => ({ + background: + 'radial-gradient(circle at 30% 30%, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 25%, transparent 60%)', + }), []) + + return ( +
+
+
+
+
+
+
+ +
+
+ + {isEdit ? '材质编辑中' : '花色详细规格'} + + + {categoryName} + +
+

+ {form.name.trim() || '未命名材质'} +

+
+
+
+ {displayColor} +
+
+ + STUDIO VERIFIED + +
+
+
+
+ ) +} + +function SubstratePreviewHeader({ + title, + badge, + subtitle, +}: { + title: string + badge: string + subtitle: string +}) { + return ( +
+
+
+ +
+
+
+ + {badge} + +
+

+ {title} +

+

{subtitle}

+
+
+
+ ) +} + function buildLiveMetrics( metrics: PlatformMetricItem[], counts: { substrates: number; finishes: number; categories: number }, @@ -1076,18 +1176,49 @@ export default function MaterialLibraryClient({ } let rightContent: ReactNode + let inspectorHeader: ReactNode = null if (panel.mode === 'empty') { rightContent = ( -
-
-

从左侧选择一项查看详情

-

或点击「新建」开始维护

+
+
+
+ +
+

+ 选择资料 +

+

+ 从左侧选择一个基材或饰面查看详情,也可以直接点击「新建」开始维护。 +

) } else if (panel.mode === 'view-finish' && selectedFinish) { const finishPreview = materialPreviewUrl(selectedFinish) + inspectorHeader = ( + + ) rightContent = (
@@ -1205,6 +1336,8 @@ export default function MaterialLibraryClient({ ) } else if (panel.mode === 'create-finish' || panel.mode === 'edit-finish') { const isCreate = panel.mode === 'create-finish' + const categoryName = categories.find((item) => item.id === finishForm.categoryId)?.name ?? '未分类' + inspectorHeader = rightContent = (
@@ -1472,6 +1605,13 @@ export default function MaterialLibraryClient({
) } else if (panel.mode === 'view-substrate' && selectedSubstrate) { + inspectorHeader = ( + + ) rightContent = (
@@ -1567,6 +1707,13 @@ export default function MaterialLibraryClient({ ) } else if (panel.mode === 'create-substrate' || panel.mode === 'edit-substrate') { const isCreate = panel.mode === 'create-substrate' + inspectorHeader = ( + + ) rightContent = (
@@ -1872,7 +2019,7 @@ export default function MaterialLibraryClient({ ) } else { rightContent = ( -
+

当前项目不存在或已被删除

) @@ -1884,15 +2031,18 @@ export default function MaterialLibraryClient({ title="基础资料库" description="饰面花色、花色分类、板材基材和基材字典的统一维护工作台。" headerActions={ - } metrics={liveMetrics} - rightPanelClassName="w-[720px] max-w-[60vw]" + rightPanelClassName="w-[760px] max-w-[65vw]" leftContent={ -
-
+
+
+

+ 资料分区 Library Sections +

-
-
- - setSearchText(event.target.value)} - placeholder={activeTab === 'finish' ? '搜索花色 / 分类 / 编码' : '搜索基材 / 字典 / 花色'} - className="w-full rounded-apple-lg border border-apple-separator bg-apple-bg-primary py-2 pl-9 pr-8 text-sm text-apple-label-primary" - /> - {searchText && ( - - )} -
- -
+
{activeTab === 'finish' && ( -
-
+
+

+ 花色分类 Finish Categories +

+
- ))} +
+

+ 标准字典 Technical Dictionaries +

+
+ {( + ['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[] + ).map((type) => ( + + ))} +
)} -
+
+
+ + setSearchText(event.target.value)} + placeholder={activeTab === 'finish' ? '快捷搜索花色名称、分类、编码...' : '快捷搜索基材名称、编码、系统名称...'} + className="w-full rounded-apple-xl border border-apple-separator/30 bg-white/60 py-2.5 pl-10 pr-10 text-sm text-apple-label-primary outline-none transition-all focus:bg-white focus:ring-2 focus:ring-apple-blue/5" + /> + {searchText && ( + + )} +
+ +
+

+ {activeTab === 'finish' ? '饰面列表 Finishes' : '基材列表 Substrates'} ( + {activeTab === 'finish' ? filteredFinishes.length : filteredSubstrates.length}) +

+ +
+
+ +
{activeTab === 'finish' ? ( filteredFinishes.length === 0 ? ( -

暂无花色

+

未找到花色

) : ( - filteredFinishes.map((item) => ( - - )) +
+ {filteredFinishes.map((item) => ( + + ))} +
) + ) : filteredSubstrates.length === 0 ? ( +

未找到基材

) : ( - filteredSubstrates.length === 0 ? ( -

暂无基材

- ) : ( - filteredSubstrates.map((item) => ( +
+ {filteredSubstrates.map((item) => ( - )) - ) + ))} +
)}
} - rightContent={rightContent} + rightContent={ +
+
+ {inspectorHeader} +
+ {rightContent} +
+
+
+ } /> } +interface RecoverableProductSkuContext { + id: string + productId: string + isActive: boolean + selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType +} + +function createEmptySelectedFamilyIds(): ManualAssemblySelectedFamilyIdsByType { + return { + desktopFamilyIds: [], + legFamilyIds: [], + connectorFamilyIds: [], + } +} + +function pushUnique(target: string[], value: string | null | undefined) { + if (!value || target.includes(value)) { + return + } + + target.push(value) +} + +function mergeSelectedFamilyIds( + target: ManualAssemblySelectedFamilyIdsByType, + incoming: ManualAssemblySelectedFamilyIdsByType, +) { + incoming.desktopFamilyIds.forEach((value) => pushUnique(target.desktopFamilyIds, value)) + incoming.legFamilyIds.forEach((value) => pushUnique(target.legFamilyIds, value)) + incoming.connectorFamilyIds.forEach((value) => pushUnique(target.connectorFamilyIds, value)) +} + +function hasSelectedFamilyIds(value: ManualAssemblySelectedFamilyIdsByType) { + return ( + value.desktopFamilyIds.length > 0 || + value.legFamilyIds.length > 0 || + value.connectorFamilyIds.length > 0 + ) +} + +function addFamilyIdByRole( + target: ManualAssemblySelectedFamilyIdsByType, + input: { + familyId: string | null | undefined + assetType?: unknown + source?: unknown + role?: unknown + }, +) { + if (!input.familyId) { + return + } + + if ( + input.assetType === 'BOARD' || + input.source === 'desktop' || + input.role === 'desktop' + ) { + pushUnique(target.desktopFamilyIds, input.familyId) + return + } + + if ( + input.assetType === 'HARDWARE' || + input.source === 'connector' || + input.role === 'connector' + ) { + pushUnique(target.connectorFamilyIds, input.familyId) + return + } + + pushUnique(target.legFamilyIds, input.familyId) +} + +function buildSelectedFamilyIdsFromSourcePreSku(sourcePreSku: { + previewNodes: unknown + variantSelection: unknown + displaySummary: unknown +} | null): ManualAssemblySelectedFamilyIdsByType { + const selectedFamilyIds = createEmptySelectedFamilyIds() + + if (!sourcePreSku) { + return selectedFamilyIds + } + + const normalizedVariantSelection = normalizeVariantSelection(sourcePreSku.variantSelection) + normalizedVariantSelection.slots.forEach((slot) => { + addFamilyIdByRole(selectedFamilyIds, { + familyId: slot.assetFamilyId, + assetType: slot.assetType, + role: slot.role, + }) + }) + normalizedVariantSelection.hardwareSelections.forEach((selection) => { + addFamilyIdByRole(selectedFamilyIds, { + familyId: selection.assetFamilyId, + assetType: selection.assetType, + role: 'connector', + }) + }) + + const workspace = normalizeAssemblyWorkspace( + typeof sourcePreSku.displaySummary === 'object' && sourcePreSku.displaySummary !== null + ? (sourcePreSku.displaySummary as Record).assemblyWorkspace + : null, + ) + workspace?.nodes.forEach((node) => { + addFamilyIdByRole(selectedFamilyIds, { + familyId: node.assetFamilyId, + source: node.source, + role: node.role, + }) + }) + + if (Array.isArray(sourcePreSku.previewNodes)) { + sourcePreSku.previewNodes.forEach((node) => { + if (!node || typeof node !== 'object') { + return + } + + const record = node as Record + addFamilyIdByRole(selectedFamilyIds, { + familyId: typeof record.assetFamilyId === 'string' ? record.assetFamilyId : null, + assetType: record.assetType, + source: record.source, + role: record.role, + }) + }) + } + + return selectedFamilyIds +} + +function resolveProductAssemblyRedirectContext(input: { + productId: string + skus: Array<{ + id: string + productId: string + isActive: boolean + sourcePreSku: { + previewNodes: unknown + variantSelection: unknown + displaySummary: unknown + } | null + }> +}): Pick< + ManualAssemblyLaunchParams, + 'entryMode' | 'sourceSkuId' | 'sourceProductId' | 'selectedFamilyIdsByType' +> & { + productRecovery?: ManualAssemblyLaunchParams['productRecovery'] + recoverableSourceSkuCount?: ManualAssemblyLaunchParams['recoverableSourceSkuCount'] +} { + const recoverableSkuContexts: RecoverableProductSkuContext[] = input.skus + .map((sku) => ({ + id: sku.id, + productId: sku.productId, + isActive: sku.isActive, + selectedFamilyIdsByType: buildSelectedFamilyIdsFromSourcePreSku(sku.sourcePreSku), + })) + .filter((sku) => hasSelectedFamilyIds(sku.selectedFamilyIdsByType)) + + const mergedSelectedFamilyIds = recoverableSkuContexts.reduce( + (accumulator, sku) => { + mergeSelectedFamilyIds(accumulator, sku.selectedFamilyIdsByType) + return accumulator + }, + createEmptySelectedFamilyIds(), + ) + + const activeRecoverableSkus = recoverableSkuContexts.filter((sku) => sku.isActive) + const resolvedSourceSku = + recoverableSkuContexts.length === 1 + ? recoverableSkuContexts[0] + : activeRecoverableSkus.length === 1 + ? activeRecoverableSkus[0] + : null + + if (resolvedSourceSku) { + return { + entryMode: 'source-sku', + sourceSkuId: resolvedSourceSku.id, + sourceProductId: resolvedSourceSku.productId, + selectedFamilyIdsByType: resolvedSourceSku.selectedFamilyIdsByType, + } + } + + if (recoverableSkuContexts.length > 1) { + return { + entryMode: 'direct', + sourceSkuId: null, + sourceProductId: input.productId, + selectedFamilyIdsByType: mergedSelectedFamilyIds, + productRecovery: 'ambiguous-source-sku', + recoverableSourceSkuCount: recoverableSkuContexts.length, + } + } + + return { + entryMode: 'direct', + sourceSkuId: null, + sourceProductId: input.productId, + selectedFamilyIdsByType: mergedSelectedFamilyIds, + productRecovery: 'missing-source-context', + recoverableSourceSkuCount: null, + } +} + export default async function AssemblyEditorPage({ params }: PageProps) { const { authUser } = await requireSession('/login') const { id } = await params @@ -39,14 +251,19 @@ export default async function AssemblyEditorPage({ params }: PageProps) { not: null, }, }, - orderBy: { - updatedAt: 'desc', - }, + orderBy: [{ isActive: 'desc' }, { updatedAt: 'desc' }], select: { id: true, productId: true, + isActive: true, + sourcePreSku: { + select: { + previewNodes: true, + variantSelection: true, + displaySummary: true, + }, + }, }, - take: 1, }, }, }) @@ -76,20 +293,21 @@ export default async function AssemblyEditorPage({ params }: PageProps) { redirect('/products') } - const recoverableSourceSku = product.skus[0] ?? null + const recoveryContext = resolveProductAssemblyRedirectContext({ + productId: product.id, + skus: product.skus, + }) redirect( buildManualAssemblyLaunchHref({ - entryMode: recoverableSourceSku ? 'source-sku' : 'direct', + entryMode: recoveryContext.entryMode, source: 'assisted-assembly', - sourceSkuId: recoverableSourceSku?.id ?? null, - sourceProductId: recoverableSourceSku?.productId ?? product.id, + sourceSkuId: recoveryContext.sourceSkuId, + sourceProductId: recoveryContext.sourceProductId, redirectedFrom: 'product-assembly', - selectedFamilyIdsByType: { - desktopFamilyIds: [], - legFamilyIds: [], - connectorFamilyIds: [], - }, + selectedFamilyIdsByType: recoveryContext.selectedFamilyIdsByType, + productRecovery: recoveryContext.productRecovery, + recoverableSourceSkuCount: recoveryContext.recoverableSourceSkuCount, }), ) } diff --git a/furniture-3d-system/src/components/3d/ModelViewer.tsx b/furniture-3d-system/src/components/3d/ModelViewer.tsx index 559a49b9..c1ef8ce8 100644 --- a/furniture-3d-system/src/components/3d/ModelViewer.tsx +++ b/furniture-3d-system/src/components/3d/ModelViewer.tsx @@ -478,6 +478,7 @@ function ModelViewerComponent({ const onGizmoInteractionStartRef = useRef(onGizmoInteractionStart); const onGizmoInteractionEndRef = useRef(onGizmoInteractionEnd); const appliedMaterialSignatureRef = useRef>(new Map()); + const lastMaterialReplaySceneVersionRef = useRef(null); const lastHighlightedPartIdsRef = useRef([]); const loadSessionRef = useRef(0); const transformInteractionActiveRef = useRef(false); @@ -1497,6 +1498,8 @@ function ModelViewerComponent({ }; const applyAssignments = async () => { + const shouldForceReplay = + lastMaterialReplaySceneVersionRef.current !== sceneLoadVersion; const assignmentByNode = new Map( materialAssignments.map((assignment) => [ assignment.nodeName, @@ -1508,13 +1511,26 @@ function ModelViewerComponent({ const signature = toMaterialSignature(assignment.material); nextSignatureMap.set(assignment.nodeName, signature); return ( + shouldForceReplay || appliedMaterialSignatureRef.current.get(assignment.nodeName) !== signature ); }); + const unresolvedNodeNames = new Set(); + + const applyMaterialToNode = ( + nodeName: string, + material: THREE.Material, + ): boolean => { + if (cancelled || !engineRef.current) { + return false; + } + return engineRef.current.applyMaterialToMesh(nodeName, material); + }; if (changedAssignments.length === 0) { appliedMaterialSignatureRef.current = nextSignatureMap; + lastMaterialReplaySceneVersionRef.current = sceneLoadVersion; return; } @@ -1522,11 +1538,9 @@ function ModelViewerComponent({ const immediateMaterial = materialManager.createImmediateMaterial( assignment.material, ); - if (cancelled || !engineRef.current) return; - engineRef.current.applyMaterialToMesh( - assignment.nodeName, - immediateMaterial, - ); + if (!applyMaterialToNode(assignment.nodeName, immediateMaterial)) { + unresolvedNodeNames.add(assignment.nodeName); + } }); await Promise.all( @@ -1534,17 +1548,45 @@ function ModelViewerComponent({ const material = await materialManager.createMaterial( assignment.material, ); - if (cancelled || !engineRef.current) return; - engineRef.current.applyMaterialToMesh(assignment.nodeName, material); + if (cancelled) return; + if (applyMaterialToNode(assignment.nodeName, material)) { + unresolvedNodeNames.delete(assignment.nodeName); + return; + } + unresolvedNodeNames.add(assignment.nodeName); }), ); if (cancelled) return; + if (shouldForceReplay && unresolvedNodeNames.size > 0) { + await new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); + + await Promise.all( + changedAssignments + .filter((assignment) => unresolvedNodeNames.has(assignment.nodeName)) + .map(async (assignment) => { + const material = await materialManager.createMaterial( + assignment.material, + ); + if (cancelled) return; + if (applyMaterialToNode(assignment.nodeName, material)) { + unresolvedNodeNames.delete(assignment.nodeName); + } + }), + ); + } + + if (cancelled) return; + assignmentByNode.forEach((_material, nodeName) => { const signature = nextSignatureMap.get(nodeName); - if (signature) { + if (signature && !unresolvedNodeNames.has(nodeName)) { appliedMaterialSignatureRef.current.set(nodeName, signature); + } else { + appliedMaterialSignatureRef.current.delete(nodeName); } }); @@ -1555,6 +1597,8 @@ function ModelViewerComponent({ appliedMaterialSignatureRef.current.delete(nodeName); } } + + lastMaterialReplaySceneVersionRef.current = sceneLoadVersion; }; applyAssignments().catch((assignmentError) => { diff --git a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.reload.test.tsx b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.reload.test.tsx index 149b8572..5f06dee6 100644 --- a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.reload.test.tsx +++ b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.reload.test.tsx @@ -1,4 +1,5 @@ import { act, render } from "@testing-library/react"; +import * as THREE from "three"; import { ModelViewer } from "@/components/3d/ModelViewer"; const loadModelMock = jest.fn(async () => undefined); @@ -12,6 +13,31 @@ const selectPartMock = jest.fn(); const resetExplodedViewMock = jest.fn(); const setExplodedViewMock = jest.fn(); +jest.mock("three/examples/jsm/controls/TransformControls.js", () => ({ + TransformControls: class { + object: THREE.Object3D | null = null; + helper = new THREE.Group(); + listeners = new Map void>(); + setSpace = jest.fn(); + setSize = jest.fn(); + setMode = jest.fn(); + attach = jest.fn((object: THREE.Object3D) => { + this.object = object; + }); + detach = jest.fn(() => { + this.object = null; + }); + addEventListener = jest.fn((eventName: string, handler: (event: unknown) => void) => { + this.listeners.set(eventName, handler); + }); + removeEventListener = jest.fn((eventName: string) => { + this.listeners.delete(eventName); + }); + getHelper = jest.fn(() => this.helper); + dispose = jest.fn(); + }, +})); + jest.mock("@/lib/3d/engine", () => ({ ThreeDEngine: jest.fn().mockImplementation(() => ({ loadModel: loadModelMock, diff --git a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx index eba9aa71..4fc5021b 100644 --- a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx +++ b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.test.tsx @@ -5,6 +5,7 @@ import { ModelViewer } from '../ModelViewer' const mockEngineInstances: Array<{ loadWorldNodes: jest.Mock + applyMaterialToMesh: jest.Mock resolveObjectByNodeName: jest.Mock }> = [] const mockTransformControlsInstances: Array<{ @@ -212,4 +213,79 @@ describe('ModelViewer', () => { expect(attachedObject?.name).toBe('node-1') }) }) + + it('世界节点重载后即使材质配置没变也会重新应用材质', async () => { + const nodeMaterial = { + id: 'finish-oak', + name: '橡木', + categoryId: 'finish', + color: '#c59a6b', + metalness: 0, + roughness: 0.62, + opacity: 1, + textureUrl: '/oak.png', + } + + const { rerender } = render( + , + ) + + await waitFor(() => { + expect(mockEngineInstances[0]?.applyMaterialToMesh).toHaveBeenCalled() + }) + + const initialApplyCount = + mockEngineInstances[0]?.applyMaterialToMesh.mock.calls.length ?? 0 + + rerender( + , + ) + + await waitFor(() => { + expect(mockEngineInstances[0]?.loadWorldNodes).toHaveBeenCalledTimes(2) + }) + + await waitFor(() => { + expect( + mockEngineInstances[0]?.applyMaterialToMesh.mock.calls.length ?? 0, + ).toBeGreaterThan(initialApplyCount) + }) + }) }) diff --git a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx index 00e35959..1d46ac84 100644 --- a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx +++ b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx @@ -22,6 +22,7 @@ import { } from '@/lib/open-product/assisted-assembly-variant' import { deriveAssemblyActiveSnapCandidate, + getAssemblyAutoSnapSupportStatus, moveAssemblyNodes, removeAssemblyNodes, instantiateAssemblyNode, @@ -66,13 +67,6 @@ function buildSnapHolePanelId(nodeId: string, holeId: string) { return `${nodeId}::hole::${holeId}` } -function getNodeStatus(node: { isSnapped?: boolean; zone: 'main' | 'staging' | 'subassembly' }) { - if (node.isSnapped) return '已连接' - if (node.zone === 'main') return '待连接' - if (node.zone === 'subassembly') return '模块处理中' - return '待装配' -} - function toOpenProductVariant(variant: AssistedAssemblyVariant) { return { id: variant.id, @@ -147,7 +141,7 @@ export function AssemblyWorkbenchV2({ workspaceKey, candidateName, statusTag: _statusTag, - warnings: _warnings, + warnings, availableVariants, onChange, className, @@ -271,6 +265,30 @@ export function AssemblyWorkbenchV2({ const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId)) const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null + const selectedNodeSnapSupport = useMemo( + () => (selectedSingleNode ? getAssemblyAutoSnapSupportStatus(selectedSingleNode) : null), + [selectedSingleNode], + ) + const selectedNodeAutoSnapNotice = useMemo(() => { + if ( + !selectedSingleNode || + selectedSingleNode.source === 'desktop' || + selectedNodeSnapSupport?.canAutoSnap + ) { + return null + } + + if (selectedNodeSnapSupport?.missingMountPatterns && selectedNodeSnapSupport?.missingHoles) { + return '当前资产缺少孔位与安装图样语义,无法自动吸附。' + } + if (selectedNodeSnapSupport?.missingMountPatterns) { + return '当前资产缺少安装图样语义,无法自动吸附。' + } + if (selectedNodeSnapSupport?.missingHoles) { + return '当前资产缺少孔位语义,无法自动吸附。' + } + return null + }, [selectedNodeSnapSupport, selectedSingleNode]) const activeSnapCandidate = useMemo( () => selectedSingleNode @@ -816,7 +834,26 @@ export function AssemblyWorkbenchV2({ : 'pointer-events-none translate-x-[calc(100%+16px)] opacity-0', )} > -
{rightDrawerContent}
+
+ {warnings.length > 0 || selectedNodeAutoSnapNotice ? ( +
+ {selectedNodeAutoSnapNotice ? ( +

+ {selectedNodeAutoSnapNotice} +

+ ) : null} + {warnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} + {rightDrawerContent} +
) : null diff --git a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx index ab6c3fc2..529a315b 100644 --- a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx +++ b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx @@ -402,6 +402,86 @@ function buildHoleSnapWorkspace() { }) } +function buildMissingSnapSemanticWorkspace() { + const baseWorkspace = buildWorkspace() + const withDesktop = instantiateAssemblyNode(baseWorkspace, { + inventoryId: 'desktop:family-board-1:1', + variant: { + ...AVAILABLE_VARIANTS[0], + assemblyBasis: { + anchor: 'desktop-center', + holes: [ + { + id: 'board-hole-1', + panelId: 'board-panel', + position: [0, 0, 0], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'board-hole-group', + surfaceFaces: ['top'], + }, + ], + holeGroups: [ + { + id: 'board-hole-group', + panelId: 'board-panel', + groupType: 'pair', + mountFace: 'top', + holeIds: ['board-hole-1'], + surfaceFaces: ['top'], + }, + ], + mountPatterns: [ + { + id: 'board-pattern-1', + panelId: 'board-panel', + mountFace: 'top', + patternType: 'single', + holeIds: ['board-hole-1'], + requiredHoleIds: ['board-hole-1'], + optionalHoleIds: [], + minRequiredHoles: 1, + preferredHoleCount: 1, + }, + ], + }, + } as never, + }) + + return instantiateAssemblyNode(withDesktop, { + inventoryId: 'leg:family-leg-1:2', + variant: { + ...AVAILABLE_VARIANTS[1], + assemblyBasis: { + attachTo: 'desktop', + holes: [ + { + id: 'leg-hole-1', + panelId: 'leg-panel', + position: [0, 0, 0], + normal: [0, 1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'leg-hole-group', + surfaceFaces: ['bottom'], + }, + ], + holeGroups: [ + { + id: 'leg-hole-group', + panelId: 'leg-panel', + groupType: 'pair', + mountFace: 'bottom', + holeIds: ['leg-hole-1'], + surfaceFaces: ['bottom'], + }, + ], + }, + } as never, + }) +} + describe('AssemblyWorkbenchV2', () => { it('默认显示统一 3D 世界,并通过悬停展开左右浮层', async () => { const actor = userEvent.setup() @@ -533,6 +613,23 @@ describe('AssemblyWorkbenchV2', () => { }) }) + it('选中缺少安装图样语义的节点时,会明确提示无法自动吸附', async () => { + const actor = userEvent.setup() + + render( + 右侧业务区
} + />, + ) + + await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' })) + + await waitFor(() => { + expect(screen.getByText('当前资产缺少安装图样语义,无法自动吸附。')).toBeInTheDocument() + }) + }) + it('选中节点后不再显示底部快捷浮条,操作统一走右键菜单', async () => { render() diff --git a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts index 37c6874a..c466e59e 100644 --- a/furniture-3d-system/src/lib/open-product/assembly-workspace.ts +++ b/furniture-3d-system/src/lib/open-product/assembly-workspace.ts @@ -530,8 +530,27 @@ function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRec .filter((item): item is AssemblyBasisHoleRecord => item !== null) } +export interface AssemblyAutoSnapSupportStatus { + canAutoSnap: boolean + missingHoles: boolean + missingMountPatterns: boolean +} + +export function getAssemblyAutoSnapSupportStatus( + node: AssemblyWorkspaceNode, +): AssemblyAutoSnapSupportStatus { + const holeCount = getNodeAssemblyHoles(node).length + const mountPatternCount = getNodeAssemblyMountPatterns(node).length + + return { + canAutoSnap: holeCount > 0 && mountPatternCount > 0, + missingHoles: holeCount === 0, + missingMountPatterns: mountPatternCount === 0, + } +} + function canUseHoleAutoSnap(node: AssemblyWorkspaceNode) { - return getNodeAssemblyMountPatterns(node).length > 0 && getNodeAssemblyHoles(node).length > 0 + return getAssemblyAutoSnapSupportStatus(node).canAutoSnap } function areHoleDiametersCompatible( diff --git a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.test.ts b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.test.ts index 6e08751f..2a4f18d3 100644 --- a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.test.ts +++ b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.test.ts @@ -21,22 +21,24 @@ describe('manual-assembly-launch', () => { source: 'assisted-assembly', sourceProductId: 'product-1', redirectedFrom: 'product-assembly', + productRecovery: 'ambiguous-source-sku', + recoverableSourceSkuCount: 2, selectedFamilyIdsByType: { - desktopFamilyIds: [], - legFamilyIds: [], + desktopFamilyIds: ['family-board'], + legFamilyIds: ['family-leg'], connectorFamilyIds: [], }, }) expect(href).toBe( - '/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly', + '/assisted-assembly?entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=ambiguous-source-sku&recoverableSourceSkuCount=2&desktopFamilyIds=family-board&legFamilyIds=family-leg', ) }) - it('会保留旧产品装配页的重定向参数,而不是把无选中资产的 direct 入口丢掉', () => { + it('会保留旧产品装配页的恢复上下文参数,而不是把 direct 入口丢掉', () => { const launchParams = parseManualAssemblyLaunchParams( createSearchParamReader( - 'entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly', + 'entryMode=direct&source=assisted-assembly&sourceProductId=product-1&redirectedFrom=product-assembly&productRecovery=ambiguous-source-sku&recoverableSourceSkuCount=2&desktopFamilyIds=family-board&legFamilyIds=family-leg', ), ) @@ -46,20 +48,41 @@ describe('manual-assembly-launch', () => { sourceSkuId: null, sourceProductId: 'product-1', redirectedFrom: 'product-assembly', + productRecovery: 'ambiguous-source-sku', + recoverableSourceSkuCount: 2, selectedFamilyIdsByType: { - desktopFamilyIds: [], - legFamilyIds: [], + desktopFamilyIds: ['family-board'], + legFamilyIds: ['family-leg'], connectorFamilyIds: [], }, }) }) - it('会为旧入口收敛生成最小必要反馈文案', () => { - expect(buildManualAssemblyLegacyFeedback('product-assembly')).toEqual({ + it('会为产品级旧入口生成更明确的恢复反馈文案', () => { + expect( + buildManualAssemblyLegacyFeedback({ + redirectedFrom: 'product-assembly', + productRecovery: 'ambiguous-source-sku', + recoverableSourceSkuCount: 2, + }), + ).toEqual({ + tone: 'error', + text: '当前产品下找到 2 个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。', + }) + expect( + buildManualAssemblyLegacyFeedback({ + redirectedFrom: 'product-assembly', + productRecovery: 'missing-source-context', + }), + ).toEqual({ + tone: 'error', + text: '当前产品还没有可恢复的源 SKU 或装配快照,已打开普通辅助装配入口。', + }) + expect(buildManualAssemblyLegacyFeedback({ redirectedFrom: 'product-assembly' })).toEqual({ tone: 'success', text: '旧产品装配入口已下线,请改用辅助装配继续处理。', }) - expect(buildManualAssemblyLegacyFeedback('sku-assembly')).toEqual({ + expect(buildManualAssemblyLegacyFeedback({ redirectedFrom: 'sku-assembly' })).toEqual({ tone: 'success', text: '旧 SKU 装配入口已迁移到辅助装配。', }) diff --git a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts index 129a62d9..d088dea9 100644 --- a/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts +++ b/furniture-3d-system/src/lib/open-product/manual-assembly-launch.ts @@ -10,6 +10,9 @@ export interface ManualAssemblySelectedFamilyIdsByType { } export type ManualAssemblyLegacyEntry = 'product-assembly' | 'sku-assembly' +export type ManualAssemblyProductRecovery = + | 'missing-source-context' + | 'ambiguous-source-sku' export interface ManualAssemblyLaunchParams { entryMode: AssemblyWorkspaceEntryMode @@ -18,6 +21,8 @@ export interface ManualAssemblyLaunchParams { sourceProductId?: string | null selectedFamilyIdsByType: ManualAssemblySelectedFamilyIdsByType redirectedFrom?: ManualAssemblyLegacyEntry | null + productRecovery?: ManualAssemblyProductRecovery | null + recoverableSourceSkuCount?: number | null } type SearchParamReader = { @@ -45,6 +50,25 @@ function normalizeLegacyEntry( return null } +function normalizeProductRecovery( + value: string | null | undefined, +): ManualAssemblyProductRecovery | null { + if (value === 'missing-source-context' || value === 'ambiguous-source-sku') { + return value + } + + return null +} + +function normalizePositiveInteger(value: string | null | undefined): number | null { + if (!value) { + return null + } + + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : null +} + export function buildManualAssemblyLaunchHref( params: ManualAssemblyLaunchParams, ) { @@ -64,6 +88,18 @@ export function buildManualAssemblyLaunchHref( searchParams.set('redirectedFrom', params.redirectedFrom) } + if (params.productRecovery) { + searchParams.set('productRecovery', params.productRecovery) + } + + if ( + typeof params.recoverableSourceSkuCount === 'number' && + Number.isFinite(params.recoverableSourceSkuCount) && + params.recoverableSourceSkuCount > 0 + ) { + searchParams.set('recoverableSourceSkuCount', String(params.recoverableSourceSkuCount)) + } + if (params.selectedFamilyIdsByType.desktopFamilyIds.length > 0) { searchParams.set('desktopFamilyIds', params.selectedFamilyIdsByType.desktopFamilyIds.join(',')) } @@ -96,6 +132,10 @@ export function parseManualAssemblyLaunchParams( const sourceSkuId = searchParams.get('sourceSkuId')?.trim() ?? null const sourceProductId = searchParams.get('sourceProductId')?.trim() ?? null const redirectedFrom = normalizeLegacyEntry(searchParams.get('redirectedFrom')?.trim()) + const productRecovery = normalizeProductRecovery(searchParams.get('productRecovery')?.trim()) + const recoverableSourceSkuCount = normalizePositiveInteger( + searchParams.get('recoverableSourceSkuCount')?.trim(), + ) const entryMode: AssemblyWorkspaceEntryMode = entryModeValue === 'asset-center' || entryModeValue === 'source-sku' ? entryModeValue @@ -118,20 +158,46 @@ export function parseManualAssemblyLaunchParams( sourceProductId, selectedFamilyIdsByType, redirectedFrom, + productRecovery, + recoverableSourceSkuCount, } } export function buildManualAssemblyLegacyFeedback( - redirectedFrom: ManualAssemblyLegacyEntry | null | undefined, -): { tone: 'success'; text: string } | null { - if (redirectedFrom === 'product-assembly') { + launchParams: + | Pick< + ManualAssemblyLaunchParams, + 'redirectedFrom' | 'productRecovery' | 'recoverableSourceSkuCount' + > + | null + | undefined, +): { tone: 'success' | 'error'; text: string } | null { + if (launchParams?.redirectedFrom === 'product-assembly') { + if (launchParams.productRecovery === 'ambiguous-source-sku') { + const count = launchParams.recoverableSourceSkuCount + return { + tone: 'error', + text: + typeof count === 'number' && count > 1 + ? `当前产品下找到 ${count} 个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。` + : '当前产品下存在多个可恢复 SKU,暂时无法自动判断该恢复哪一个,已先按产品级可推导资产打开辅助装配。建议从具体 SKU 入口进入。', + } + } + + if (launchParams.productRecovery === 'missing-source-context') { + return { + tone: 'error', + text: '当前产品还没有可恢复的源 SKU 或装配快照,已打开普通辅助装配入口。', + } + } + return { tone: 'success', text: '旧产品装配入口已下线,请改用辅助装配继续处理。', } } - if (redirectedFrom === 'sku-assembly') { + if (launchParams?.redirectedFrom === 'sku-assembly') { return { tone: 'success', text: '旧 SKU 装配入口已迁移到辅助装配。', diff --git a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts index 8d7fba9a..91acad3a 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-cad-import.service.test.ts @@ -169,6 +169,7 @@ describe('assetCadImportService', () => { modelFile: 'desktop.glb', panelCount: 1, units: 'mm', + coordinateSystem: 'cad-local', originOffset: [10, 20, 30], modelBounds: [0, 0, 0, 1200, 600, 25], panels: [ @@ -193,6 +194,7 @@ describe('assetCadImportService', () => { holeGroupId: 'group-1', holeGroupType: 'pair', holeGroupRole: 'mount', + surfaceFaces: ['bottom'], suggestedFasteners: [ { category: 'dowel', @@ -213,6 +215,24 @@ describe('assetCadImportService', () => { holeIds: ['hole-1'], position: [100, 50, 0], normal: [0, 0, 1], + mountFace: 'bottom', + surfaceFaces: ['bottom'], + }, + ], + mountPatterns: [ + { + id: 'pattern-1', + panelId: 'panel-1', + mountFace: 'bottom', + patternType: 'single', + holeIds: ['hole-1'], + requiredHoleIds: ['hole-1'], + optionalHoleIds: [], + minRequiredHoles: 1, + preferredHoleCount: 1, + symmetry: false, + position: [100, 50, 0], + normal: [0, 0, 1], }, ], } as never, @@ -271,12 +291,15 @@ describe('assetCadImportService', () => { expect(createVariantInput.assemblyBasis).toEqual( expect.objectContaining({ source: 'CAD_SINGLE_ASSET_IMPORT', + coordinateSystem: 'cad-local', holeCount: 1, + mountPatternCount: 1, suggestedFastenerCount: 1, holes: [ expect.objectContaining({ id: 'hole-1', holeGroupId: 'group-1', + surfaceFaces: ['bottom'], suggestedFasteners: [ expect.objectContaining({ category: 'dowel', @@ -289,6 +312,16 @@ describe('assetCadImportService', () => { expect.objectContaining({ id: 'group-1', panelId: 'panel-1', + mountFace: 'bottom', + surfaceFaces: ['bottom'], + }), + ], + mountPatterns: [ + expect.objectContaining({ + id: 'pattern-1', + panelId: 'panel-1', + mountFace: 'bottom', + holeIds: ['hole-1'], }), ], }), @@ -315,6 +348,12 @@ describe('assetCadImportService', () => { id: 'group-1', }), ], + mountPatterns: [ + expect.objectContaining({ + id: 'pattern-1', + mountFace: 'bottom', + }), + ], }), }), ) diff --git a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts index b5c6c15b..9d1b2dd3 100644 --- a/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/asset-center.service.test.ts @@ -1366,4 +1366,167 @@ describe('assetCenterService', () => { }), ]) }) + + it('老资产 assemblyBasis 缺少安装图样时,会从 businessData.cadData 运行时恢复完整装配语义', async () => { + mockedAssetVariantRepository.findManyByFamilyIds.mockResolvedValue([ + { + id: 'variant-board-top-legacy', + assetFamilyId: 'family-board-top', + code: 'BOARD-TOP-LEGACY', + name: '桌面板历史款', + identityKey: 'BOARD|code=BOARD-TOP-LEGACY', + assetType: 'BOARD', + status: 'AVAILABLE', + sizeLabel: '1200', + substrateId: 'substrate-particle-board', + finishId: 'finish-white-oak', + dimensions: { length: 1200, width: 600, thickness: 25 }, + modelUrl: '/models/board-top-legacy.glb', + thumbnailUrl: '/thumbs/board-top-legacy.png', + mountPoints: [{ name: 'origin', position: [0, 0, 0] }], + assemblyBasis: { + source: 'CAD_SINGLE_ASSET_IMPORT', + holes: [ + { + id: 'hole-1', + panelId: 'panel-1', + position: [100, 0, 50], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'group-1', + }, + ], + holeGroups: [ + { + id: 'group-1', + panelId: 'panel-1', + groupType: 'pair', + holeIds: ['hole-1'], + }, + ], + }, + compatibilityRules: null, + namingFields: { panelType: 'top_panel' }, + businessData: { + cadData: { + source: 'hardware.json', + pluginVersion: '2.2.15', + units: 'mm', + coordinateSystem: 'cad-local', + panelCount: 1, + panels: [], + holes: [ + { + id: 'hole-1', + panelId: 'panel-1', + position: [100, 0, 50], + normal: [0, -1, 0], + diameter: 8, + depth: 12, + holeGroupId: 'group-1', + surfaceFaces: ['bottom'], + }, + ], + holeGroups: [ + { + id: 'group-1', + panelId: 'panel-1', + groupType: 'pair', + holeIds: ['hole-1'], + position: [100, 0, 50], + normal: [0, -1, 0], + mountFace: 'bottom', + surfaceFaces: ['bottom'], + }, + ], + mountPatterns: [ + { + id: 'pattern-1', + panelId: 'panel-1', + mountFace: 'bottom', + patternType: 'single', + holeIds: ['hole-1'], + requiredHoleIds: ['hole-1'], + optionalHoleIds: [], + minRequiredHoles: 1, + preferredHoleCount: 1, + position: [100, 0, 50], + normal: [0, -1, 0], + }, + ], + hardwareTypes: [], + hardwareSummary: [], + }, + }, + weightKg: 18.5, + costPrice: 199, + inheritedFromVariantId: null, + createdBy: 'user-1', + creator: { id: 'user-1', name: '测试用户' }, + submittedAt: null, + submittedById: null, + submitter: null, + reviewedAt: new Date('2026-03-31T10:00:00.000Z'), + reviewedById: 'reviewer-1', + reviewer: { id: 'reviewer-1', name: '审核员' }, + rejectedAt: null, + rejectedById: null, + rejecter: null, + rejectionReason: null, + lastModifiedById: 'user-1', + lastModifier: { id: 'user-1', name: '测试用户' }, + assetFamily: { + id: 'family-board-top', + code: 'BOARD-TOP', + name: '桌面板资产族', + type: 'BOARD', + }, + substrate: { + id: 'substrate-particle-board', + code: 'PB18', + name: '颗粒板', + }, + finish: { + id: 'finish-white-oak', + name: '白橡', + }, + createdAt: new Date('2026-03-31T10:00:00.000Z'), + updatedAt: new Date('2026-03-31T10:00:00.000Z'), + }, + ] as never) + + const result = await assetCenterService.listVariantsByFamilyIds( + ['family-board-top'], + { status: 'AVAILABLE' }, + ) + + expect(result).toEqual([ + expect.objectContaining({ + id: 'variant-board-top-legacy', + assemblyBasis: expect.objectContaining({ + coordinateSystem: 'cad-local', + holes: [ + expect.objectContaining({ + id: 'hole-1', + surfaceFaces: ['bottom'], + }), + ], + holeGroups: [ + expect.objectContaining({ + id: 'group-1', + mountFace: 'bottom', + surfaceFaces: ['bottom'], + }), + ], + mountPatterns: [ + expect.objectContaining({ + id: 'pattern-1', + mountFace: 'bottom', + }), + ], + }), + }), + ]) + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.mapper.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.mapper.test.ts new file mode 100644 index 00000000..82f1a289 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/draft-group.mapper.test.ts @@ -0,0 +1,158 @@ +import { + normalizeAssemblyWorkspace, + normalizeVariantSelection, +} from '../draft-group.mapper' + +describe('draft-group.mapper', () => { + it('会在装配工作区快照里保留完整 CAD 装配语义', () => { + const result = normalizeAssemblyWorkspace({ + source: 'assisted-assembly', + entryMode: 'direct', + inventory: [], + nodes: [ + { + nodeId: 'node-1', + role: 'leg', + label: '桌腿A', + assetFamilyId: 'family-leg-1', + variantId: 'variant-leg-1', + code: 'LEG-A-001', + name: '桌腿A', + modelUrl: '/leg.glb', + position: [0, 0, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + zone: 'main', + source: 'leg', + inventoryId: 'leg:family-leg-1:1', + slotKey: 'leg-1', + roleRecommendation: 'leg', + roleConfirmed: 'leg', + weakSlot: { role: 'leg', parentRolePath: null, instanceIndex: 1 }, + targetPosition: [0, 0, 0], + targetRotation: [0, 0, 0], + isSnapped: false, + subassemblyId: null, + mountPoints: [], + assemblyBasis: { + coordinateSystem: 'cad-local', + holes: [ + { + id: 'hole-1', + panelId: 'panel-1', + position: [0, 0, 0], + normal: [0, 1, 0], + diameter: 8, + depth: 12, + surfaceFaces: ['bottom'], + }, + ], + holeGroups: [ + { + id: 'group-1', + panelId: 'panel-1', + groupType: 'pair', + holeIds: ['hole-1'], + mountFace: 'bottom', + surfaceFaces: ['bottom'], + }, + ], + mountPatterns: [ + { + id: 'pattern-1', + panelId: 'panel-1', + mountFace: 'bottom', + patternType: 'single', + holeIds: ['hole-1'], + requiredHoleIds: ['hole-1'], + optionalHoleIds: [], + minRequiredHoles: 1, + preferredHoleCount: 1, + }, + ], + }, + }, + ], + subassemblies: [], + relations: [], + validation: { + level: 'PASSED', + blockingIssues: [], + warnings: [], + }, + }) + + expect(result?.nodes[0]?.assemblyBasis).toEqual( + expect.objectContaining({ + coordinateSystem: 'cad-local', + holes: [ + expect.objectContaining({ + id: 'hole-1', + surfaceFaces: ['bottom'], + }), + ], + holeGroups: [ + expect.objectContaining({ + id: 'group-1', + mountFace: 'bottom', + surfaceFaces: ['bottom'], + }), + ], + mountPatterns: [ + expect.objectContaining({ + id: 'pattern-1', + mountFace: 'bottom', + holeIds: ['hole-1'], + }), + ], + }), + ) + }) + + it('会在规格选择快照里保留完整 assemblyBasis,避免草稿组回读时掉装配语义', () => { + const result = normalizeVariantSelection({ + slots: [ + { + slotKey: 'desktop-1', + role: 'desktop', + assetFamilyId: 'family-board-1', + variantId: 'variant-board-1', + code: 'BOARD-A-001', + name: '桌面A', + assetType: 'BOARD', + dimensions: { length: 1200, width: 600, thickness: 25 }, + modelUrl: '/board.glb', + mountPoints: [], + assemblyBasis: { + coordinateSystem: 'cad-local', + mountPatterns: [ + { + id: 'pattern-1', + panelId: 'panel-1', + mountFace: 'top', + patternType: 'single', + holeIds: ['hole-1'], + minRequiredHoles: 1, + preferredHoleCount: 1, + }, + ], + }, + }, + ], + hardwareSelections: [], + }) + + expect(result.slots[0]?.assemblyBasis).toEqual( + expect.objectContaining({ + coordinateSystem: 'cad-local', + mountPatterns: [ + expect.objectContaining({ + id: 'pattern-1', + mountFace: 'top', + holeIds: ['hole-1'], + }), + ], + }), + ) + }) +}) diff --git a/furniture-3d-system/src/server/services/asset-cad-import.service.ts b/furniture-3d-system/src/server/services/asset-cad-import.service.ts index bac790a4..5f6d132b 100644 --- a/furniture-3d-system/src/server/services/asset-cad-import.service.ts +++ b/furniture-3d-system/src/server/services/asset-cad-import.service.ts @@ -235,6 +235,7 @@ function buildAssemblyBasis(params: { }): Record { const holes = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holes ?? []) const holeGroups = params.assetType === 'HARDWARE' ? [] : (params.cadData?.holeGroups ?? []) + const mountPatterns = params.assetType === 'HARDWARE' ? [] : (params.cadData?.mountPatterns ?? []) const edgeFeatures = params.assetType === 'BOARD' ? (params.cadExportData.edgeFeatures ?? []) : [] const suggestedFastenerCount = holes.reduce( (sum, hole) => sum + (hole.suggestedFasteners?.length ?? 0), @@ -250,9 +251,12 @@ function buildAssemblyBasis(params: { panelCount: params.cadData?.panels.length ?? 0, holeCount: holes.length, hardwareTypeCount: params.cadData?.hardwareTypes.length ?? 0, + coordinateSystem: params.cadData?.coordinateSystem ?? params.cadExportData.coordinateSystem ?? null, + mountPatternCount: mountPatterns.length, suggestedFastenerCount, holes, holeGroups, + mountPatterns, edgeFeatureCount: edgeFeatures.length, edgeFeatures, } @@ -368,9 +372,11 @@ class AssetCadImportService { const cadData = buildPersistedSkuCadData({ pluginVersion: parseResult.cadExportData.pluginVersion, units: parseResult.cadExportData.units, + coordinateSystem: parseResult.cadExportData.coordinateSystem, panels: parseResult.cadExportData.panels, holes: parseResult.cadExportData.holes, holeGroups: (parseResult.cadExportData as unknown as { holeGroups?: unknown }).holeGroups, + mountPatterns: (parseResult.cadExportData as unknown as { mountPatterns?: unknown }).mountPatterns, hardwareTypes: parseResult.cadExportData.hardwareTypes, hardwareSummary: parseResult.cadExportData.hardwareSummary, }) diff --git a/furniture-3d-system/src/server/services/asset-center.service.ts b/furniture-3d-system/src/server/services/asset-center.service.ts index ca694300..9f037f80 100644 --- a/furniture-3d-system/src/server/services/asset-center.service.ts +++ b/furniture-3d-system/src/server/services/asset-center.service.ts @@ -29,6 +29,7 @@ import { writeAssetVariantUvConfig, type AssetVariantUvConfig, } from '@/lib/asset-variant-render-config' +import type { PersistedSkuCadData } from '@/lib/sku-cad-data' import { buildSubstrateDisplayName, calculateEstimatedCost, @@ -231,9 +232,119 @@ function decimalToNumber(value: Prisma.Decimal | number | null | undefined): num return value === null || value === undefined ? null : Number(value) } +function asRecordArray(value: unknown): Record[] { + return Array.isArray(value) + ? value.filter((item): item is Record => isRecord(item)) + : [] +} + +function mergeAssemblyBasisRecordArray( + primaryValue: unknown, + fallbackValue: unknown, +): Record[] | undefined { + const primary = asRecordArray(primaryValue) + const fallback = asRecordArray(fallbackValue) + + if (primary.length === 0 && fallback.length === 0) { + return undefined + } + + if (primary.length === 0) { + return fallback.map((item) => ({ ...item })) + } + + const fallbackById = new Map( + fallback + .filter((item) => typeof item.id === 'string' && item.id.length > 0) + .map((item) => [item.id as string, item]), + ) + + const merged = primary.map((item) => { + const id = typeof item.id === 'string' ? item.id : null + const fallbackItem = id ? fallbackById.get(id) ?? null : null + return fallbackItem ? { ...fallbackItem, ...item } : { ...item } + }) + + const seenIds = new Set( + primary + .map((item) => (typeof item.id === 'string' ? item.id : null)) + .filter((item): item is string => Boolean(item)), + ) + + fallback.forEach((item) => { + const id = typeof item.id === 'string' ? item.id : null + if (id && seenIds.has(id)) { + return + } + merged.push({ ...item }) + }) + + return merged +} + +function readCadDataFromBusinessData( + businessData: Record | null, +): PersistedSkuCadData | null { + if (!businessData || !isRecord(businessData.cadData)) { + return null + } + + return businessData.cadData as unknown as PersistedSkuCadData +} + +function recoverAssemblyBasis( + assemblyBasis: Record | null, + businessData: Record | null, +): Record | null { + const cadData = readCadDataFromBusinessData(businessData) + if (!cadData) { + return assemblyBasis + } + + const recoveredHoles = mergeAssemblyBasisRecordArray(assemblyBasis?.holes, cadData.holes) + const recoveredHoleGroups = mergeAssemblyBasisRecordArray( + assemblyBasis?.holeGroups, + cadData.holeGroups, + ) + const recoveredMountPatterns = mergeAssemblyBasisRecordArray( + assemblyBasis?.mountPatterns, + cadData.mountPatterns, + ) + + if ( + !assemblyBasis && + !cadData.coordinateSystem && + !recoveredHoles && + !recoveredHoleGroups && + !recoveredMountPatterns + ) { + return null + } + + const recoveredBasis = assemblyBasis ? { ...assemblyBasis } : {} + if (!('coordinateSystem' in recoveredBasis) && cadData.coordinateSystem) { + recoveredBasis.coordinateSystem = cadData.coordinateSystem + } + if (recoveredHoles) { + recoveredBasis.holes = recoveredHoles + } + if (recoveredHoleGroups) { + recoveredBasis.holeGroups = recoveredHoleGroups + } + if (recoveredMountPatterns) { + recoveredBasis.mountPatterns = recoveredMountPatterns + } + + return recoveredBasis +} + function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { const businessData = isRecord(record.businessData) ? record.businessData : null const uvConfig = readAssetVariantUvConfig(businessData) + const assemblyBasis = recoverAssemblyBasis( + isRecord(record.assemblyBasis) ? record.assemblyBasis : null, + businessData, + ) return { id: record.id, @@ -257,7 +368,7 @@ function toAssetVariantDTO(record: AssetVariantWithRelations): AssetVariantDTO { mountPoints: Array.isArray(record.mountPoints) ? (record.mountPoints as unknown as OpenProductMountPoint[]) : [], - assemblyBasis: isRecord(record.assemblyBasis) ? record.assemblyBasis : null, + assemblyBasis, compatibilityRules: isRecord(record.compatibilityRules) ? record.compatibilityRules : null, namingFields: isRecord(record.namingFields) ? record.namingFields : null, businessData,