diff --git a/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx b/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx index f46a1af1..c1e5df99 100644 --- a/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx +++ b/furniture-3d-system/src/app/__tests__/assisted-assembly-page.test.tsx @@ -113,7 +113,10 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({ families: Array<{ id: string }> variants: Array<{ id: string }> initialLaunchParams?: { entryMode?: string | null; sourceSkuId?: string | null } | null - initialWorkspace?: { sourceSkuId?: string | null; nodes?: Array<{ nodeId: string }> } | null + initialWorkspace?: { + sourceSkuId?: string | null + nodes?: Array<{ nodeId: string; assemblyBasis?: Record | null }> + } | null initialFeedback?: { tone?: string; text?: string } | null }) => (
@@ -129,6 +132,9 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
{initialLaunchParams?.sourceSkuId ?? 'none'}
{initialWorkspace?.sourceSkuId ?? 'none'}
{initialWorkspace?.nodes?.[0]?.nodeId ?? 'none'}
+
+ {String(initialWorkspace?.nodes?.[0]?.assemblyBasis?.coordinateSystem ?? 'none')} +
{initialFeedback?.text ?? 'none'}
), @@ -341,4 +347,70 @@ describe('AssistedAssemblyPage', () => { expect(screen.getByTestId('workspace-first-node-id')).toHaveTextContent('workspace-node-1') expect(screen.getByTestId('feedback-text')).toHaveTextContent('当前已按源 SKU MODEL-001 的装配结果恢复工作台。') }) + + it('源 SKU fallback 恢复 previewNodes 时,会保留节点上的 assemblyBasis 坐标系语义', async () => { + jest.mocked(prisma.productSKU.findUnique).mockResolvedValue({ + id: 'sku-source-2', + productId: 'product-source-2', + product: { + id: 'product-source-2', + name: '源产品 2', + modelNumber: 'MODEL-002', + createdBy: 'user-1', + departmentId: 'dept-1', + visibility: 'PRIVATE', + collaboratorUsers: [], + creator: { + departmentId: 'dept-1', + teamId: 'team-1', + }, + }, + sourcePreSku: { + previewNodes: [ + { + variantId: 'variant-board', + assetFamilyId: 'family-board', + role: 'desktop', + position: [0, 720, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + assemblyBasis: { + coordinateSystem: 'cad-z-up', + holes: [], + holeGroups: [], + mountPatterns: [], + }, + }, + ], + variantSelection: { + slots: [ + { + slotKey: 'desktop-1', + role: 'desktop', + assetFamilyId: 'family-board', + assetType: 'BOARD', + variantId: 'variant-board', + mountPoints: [], + assemblyBasis: { anchor: 'desktop-center' }, + }, + ], + hardwareSelections: [], + }, + displaySummary: null, + }, + } as never) + + render( + await AssistedAssemblyPage({ + searchParams: Promise.resolve({ + entryMode: 'source-sku', + sourceSkuId: 'sku-source-2', + }), + }), + ) + + expect(screen.getByTestId('launch-entry-mode')).toHaveTextContent('source-sku') + expect(screen.getByTestId('workspace-source-sku-id')).toHaveTextContent('sku-source-2') + expect(screen.getByTestId('workspace-first-node-coordinate-system')).toHaveTextContent('cad-z-up') + }) }) diff --git a/furniture-3d-system/src/app/assisted-assembly/page.tsx b/furniture-3d-system/src/app/assisted-assembly/page.tsx index a567e20f..d5585b98 100644 --- a/furniture-3d-system/src/app/assisted-assembly/page.tsx +++ b/furniture-3d-system/src/app/assisted-assembly/page.tsx @@ -3,8 +3,12 @@ import { headers } from 'next/headers' import { canViewProduct } from '@/lib/permissions' import { prisma } from '@/lib/prisma' import { requireSession } from '@/lib/server-session' -import { buildAssetThumbnailRenderUrl, type AssetThumbnailRenderAssetType } from '@/lib/asset-thumbnail-auth' +import { + buildAssetThumbnailImageUrl, + type AssetThumbnailRenderAssetType, +} from '@/lib/asset-thumbnail-auth' import { toMaterialConfig } from '@/lib/material-config' +import { applyAssetVariantUvConfigToMaterial } from '@/lib/asset-variant-render-config' import { buildAssemblyWorkspaceFamilies, buildManualAssemblyWorkspace, @@ -102,6 +106,31 @@ function normalizeMountPoints(value: unknown): OpenProductMountPoint[] { return normalized } +function normalizeAssemblyBasisRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + + return value as Record +} + +function mergeAssemblyBasisRecords( + previewNodeAssemblyBasis: unknown, + selectionAssemblyBasis: unknown, +): Record | null { + const previewBasis = normalizeAssemblyBasisRecord(previewNodeAssemblyBasis) + const selectionBasis = normalizeAssemblyBasisRecord(selectionAssemblyBasis) + + if (previewBasis && selectionBasis) { + return { + ...previewBasis, + ...selectionBasis, + } + } + + return selectionBasis ?? previewBasis ?? null +} + function buildSelectedFamilyIdsFromVariantSelection( variantSelection: unknown, ): ManualAssemblySelectedFamilyIdsByType { @@ -275,7 +304,10 @@ function buildSourceSkuWorkspaceFallback(input: { mountPoints: normalizeMountPoints( slotSelection?.mountPoints ?? hardwareSelection?.mountPoints ?? [], ), - assemblyBasis: slotSelection?.assemblyBasis ?? hardwareSelection?.assemblyBasis ?? null, + assemblyBasis: mergeAssemblyBasisRecords( + node.assemblyBasis, + slotSelection?.assemblyBasis ?? hardwareSelection?.assemblyBasis ?? null, + ), compatibilityRules: null, materialConfig: node.materialConfig && typeof node.materialConfig === 'object' @@ -501,10 +533,16 @@ export default async function AssistedAssemblyPage({ finishes.map((finish) => [finish.id, toMaterialConfig(finish)]), ) const runtimeSelectableVariants: AssistedAssemblyVariant[] = selectableVariants.map((variant) => { - const materialConfig = variant.finishId ? finishMaterialById.get(variant.finishId) ?? null : null + const finishMaterialConfig = variant.finishId + ? finishMaterialById.get(variant.finishId) ?? null + : null + const materialConfig = applyAssetVariantUvConfigToMaterial( + finishMaterialConfig, + variant.uvConfig, + ) const runtimeThumbnailUrl = materialConfig && variant.modelUrl - ? buildAssetThumbnailRenderUrl(requestOrigin, { + ? buildAssetThumbnailImageUrl(requestOrigin, { modelUrl: variant.modelUrl, assetType: variant.assetType as AssetThumbnailRenderAssetType, expiresAt: Date.now() + 24 * 60 * 60 * 1000, diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/assembly/page.tsx b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/assembly/page.tsx index 81a96ea9..4788c8b1 100644 --- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/assembly/page.tsx +++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/assembly/page.tsx @@ -4,7 +4,7 @@ import { prisma } from '@/lib/prisma' import { canViewProduct } from '@/lib/permissions' import { requireSession } from '@/lib/server-session' import AssemblyEditorWorkspace from '../../../assembly/AssemblyEditorWorkspace' -import { type StepData } from '@/components/assembly' +import { type PanelInfo, type StepData } from '@/components/assembly' import { extractResponseData, isApiFailure, isRecord, readJsonSafely } from '@/lib/api-response' import { buildHoleOverlayDataFromPersistedHoles } from '@/lib/hole-overlay-data' import { @@ -263,7 +263,7 @@ export default async function SKUAssemblyEditorPage({ params }: PageProps) { cookieHeader, }) const skuHoleOverlayData = buildHoleOverlayDataFromPersistedHoles(scopedHoles) - const displayPanels = enrichPanelsWithSkuCadSpatial( + const displayPanels = enrichPanelsWithSkuCadSpatial( scopedPanels.map((p) => ({ id: p.id, code: p.code, diff --git a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx index 497864ee..70f0be81 100644 --- a/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx +++ b/furniture-3d-system/src/app/products/[id]/skus/[skuId]/page.tsx @@ -4,7 +4,7 @@ import { prisma } from '@/lib/prisma' import { canViewProduct } from '@/lib/permissions' import { requireSession } from '@/lib/server-session' import { SKUDetailClient } from './SKUDetailClient' -import type { AssemblyStep, CameraPosition, Transform } from '@/types/product' +import type { AssemblyStep, CameraPosition, Panel, Transform } from '@/types/product' import { extractResponseData, isApiFailure, isRecord, readJsonSafely } from '@/lib/api-response' import { buildHoleOverlayDataFromPersistedHoles } from '@/lib/hole-overlay-data' import { @@ -349,7 +349,7 @@ export default async function SKUDetailPage({ params }: SKUDetailPageProps) { orderBy: [{ panelId: 'asc' }, { createdAt: 'asc' }], }) const skuHoleOverlayData = buildHoleOverlayDataFromPersistedHoles(scopedHoles) - const displayPanels = enrichPanelsWithSkuCadSpatial( + const displayPanels = enrichPanelsWithSkuCadSpatial( scopedPanels.map((p) => ({ id: p.id, productId: p.productId, diff --git a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx index fbe9f8d5..00e35959 100644 --- a/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx +++ b/furniture-3d-system/src/components/platform/AssemblyWorkbenchV2.tsx @@ -14,11 +14,14 @@ import { } from './AssemblyMaterialDock' import { AssemblyMaterialCard } from './AssemblyMaterialCard' import { ModelPreviewPanel } from './ModelPreviewPanel' +import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer' import { getAssistedAssemblyVariantMaterialConfig, + resolveAssistedAssemblyNodeMaterialConfig, type AssistedAssemblyVariant, } from '@/lib/open-product/assisted-assembly-variant' import { + deriveAssemblyActiveSnapCandidate, moveAssemblyNodes, removeAssemblyNodes, instantiateAssemblyNode, @@ -59,6 +62,10 @@ function getSourceLabel(source: AssemblyWorkspaceNode['source']) { return '零件' } +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 '待连接' @@ -80,6 +87,7 @@ function toOpenProductVariant(variant: AssistedAssemblyVariant) { mountPoints: variant.mountPoints, substrateId: variant.substrateId, finishId: variant.finishId, + uvConfig: variant.uvConfig, weightKg: variant.weightKg, costPrice: variant.costPrice, assemblyBasis: variant.assemblyBasis, @@ -263,6 +271,75 @@ export function AssemblyWorkbenchV2({ const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId)) const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null + const activeSnapCandidate = useMemo( + () => + selectedSingleNode + ? deriveAssemblyActiveSnapCandidate(presentWorkspace, selectedSingleNode.nodeId) + : null, + [presentWorkspace, selectedSingleNode], + ) + const highlightedNodeNames = useMemo(() => { + if (!activeSnapCandidate?.targetNodeId) { + return [] + } + + return [activeSnapCandidate.targetNodeId] + }, [activeSnapCandidate]) + const holeOverlayState = useMemo(() => { + if (!selectedSingleNode || !activeSnapCandidate) { + return { + holes: [] as HoleOverlayData[], + activeHolePanelIds: [] as string[], + panelNodeNameByPanelId: {} as Record, + panelLabelByPanelId: {} as Record, + } + } + + const targetNode = + presentWorkspace.nodes.find((node) => node.nodeId === activeSnapCandidate.targetNodeId) ?? null + const sourcePanelId = buildSnapHolePanelId( + activeSnapCandidate.sourceNodeId, + activeSnapCandidate.sourceHoleId, + ) + const targetPanelId = buildSnapHolePanelId( + activeSnapCandidate.targetNodeId, + activeSnapCandidate.targetHoleId, + ) + + return { + holes: [ + { + id: sourcePanelId, + panelId: sourcePanelId, + position: activeSnapCandidate.sourceHoleWorldPosition, + diameter: activeSnapCandidate.sourceHoleDiameter ?? 8, + depth: activeSnapCandidate.sourceHoleDepth ?? 12, + normal: activeSnapCandidate.sourceHoleNormal, + holeType: 'assembly-snap-source', + confidence: 1, + }, + { + id: targetPanelId, + panelId: targetPanelId, + position: activeSnapCandidate.targetHoleWorldPosition, + diameter: activeSnapCandidate.targetHoleDiameter ?? 8, + depth: activeSnapCandidate.targetHoleDepth ?? 12, + normal: activeSnapCandidate.targetHoleNormal, + holeType: 'assembly-snap-target', + confidence: 1, + }, + ], + activeHolePanelIds: [sourcePanelId, targetPanelId], + panelNodeNameByPanelId: { + [sourcePanelId]: selectedSingleNode.nodeId, + [targetPanelId]: activeSnapCandidate.targetNodeId, + }, + panelLabelByPanelId: { + [sourcePanelId]: selectedSingleNode.name, + [targetPanelId]: targetNode?.name ?? activeSnapCandidate.targetNodeId, + }, + } + }, [activeSnapCandidate, presentWorkspace.nodes, selectedSingleNode]) const preferredInventoryId = useMemo( () => getPreferredInventoryId(presentWorkspace.inventory, availableVariantMap), [availableVariantMap, presentWorkspace.inventory], @@ -298,9 +375,10 @@ export function AssemblyWorkbenchV2({ const stageMaterialAssignments = useMemo(() => { return presentWorkspace.nodes.flatMap((node) => { - const materialConfig = - node.materialConfig ?? - getAssistedAssemblyVariantMaterialConfig(availableVariantById.get(node.variantId)) + const materialConfig = resolveAssistedAssemblyNodeMaterialConfig({ + variant: availableVariantById.get(node.variantId), + nodeMaterialConfig: node.materialConfig, + }) if (!materialConfig) { return [] @@ -772,6 +850,11 @@ export function AssemblyWorkbenchV2({ onGizmoInteractionStart={handleTransformStart} onGizmoInteractionEnd={handleTransformEnd} onWorldNodeTransformChange={handleWorldNodeTransformChange} + holes={holeOverlayState.holes} + activeHolePanelIds={holeOverlayState.activeHolePanelIds} + panelNodeNameByPanelId={holeOverlayState.panelNodeNameByPanelId} + panelLabelByPanelId={holeOverlayState.panelLabelByPanelId} + highlightedNodeNames={highlightedNodeNames} overlayLeft={materialSelectionZone} overlayLeftClassName="left-0 top-[88px] bottom-6" overlayRight={rightDrawer} diff --git a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx index fe1e7bb3..407a00ea 100644 --- a/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx +++ b/furniture-3d-system/src/components/platform/ModelPreviewPanel.tsx @@ -5,6 +5,7 @@ import { Box } from 'lucide-react' import { EmptyState } from '@/components/ui/EmptyState' import { CompositeAssetPreview } from '@/components/platform/CompositeAssetPreview' import { ViewportStageShell, type ViewportPresetKey } from '@/components/platform/ViewportStageShell' +import type { HoleData as HoleOverlayData } from '@/components/3d/HoleViewer' import type { NodePositionOffset, NodeRotationState, @@ -58,10 +59,18 @@ interface ModelPreviewPanelProps { selectableNodeNames?: string[] nodePositionOffsets?: NodePositionOffset[] nodeRotationStates?: NodeRotationState[] + highlightedNodeNames?: string[] materialAssignments?: Array<{ nodeName: string material: import('@/lib/3d/material-manager').MaterialConfig }> + holes?: HoleOverlayData[] + activeHolePanelIds?: string[] + leaderPanelIds?: string[] + panelNodeNameByPanelId?: Record + panelLabelByPanelId?: Record + showPanelLeaders?: boolean + showHoleHighlights?: boolean onGizmoInteractionStart?: (nodeName: string) => void onGizmoInteractionEnd?: (nodeName: string) => void onNodePositionOffsetChange?: (payload: { @@ -154,7 +163,15 @@ export function ModelPreviewPanel({ selectableNodeNames = [], nodePositionOffsets = [], nodeRotationStates = [], + highlightedNodeNames = [], materialAssignments = [], + holes = [], + activeHolePanelIds = [], + leaderPanelIds = [], + panelNodeNameByPanelId = {}, + panelLabelByPanelId = {}, + showPanelLeaders = false, + showHoleHighlights = true, onGizmoInteractionStart, onGizmoInteractionEnd, onNodePositionOffsetChange, @@ -344,6 +361,7 @@ export function ModelPreviewPanel({ gizmoMode={gizmoMode} onEngineReady={handleEngineReady} onModelNodesChange={onModelNodesChange} + highlightedParts={highlightedNodeNames} selectableNodeNames={selectableNodeNames} selectedNodeName={selectedNodeName} onNodeSelect={onNodeSelect} @@ -352,6 +370,13 @@ export function ModelPreviewPanel({ onGizmoInteractionEnd={onGizmoInteractionEnd} onWorldNodeTransformChange={onWorldNodeTransformChange} materialAssignments={materialAssignments} + holes={holes} + activeHolePanelIds={activeHolePanelIds} + leaderPanelIds={leaderPanelIds} + panelNodeNameByPanelId={panelNodeNameByPanelId} + panelLabelByPanelId={panelLabelByPanelId} + showPanelLeaders={showPanelLeaders} + showHoleHighlights={showHoleHighlights} /> ) : hasPreviewNodes ? ( @@ -362,22 +387,23 @@ export function ModelPreviewPanel({ + enableTranslateGizmo={enableTranslateGizmo} + gizmoMode={gizmoMode} + nodePositionOffsets={nodePositionOffsets} + nodeRotationStates={nodeRotationStates} + onEngineReady={handleEngineReady} + onModelNodesChange={onModelNodesChange} + highlightedParts={highlightedNodeNames} + selectableNodeNames={selectableNodeNames} + selectedNodeName={selectedNodeName} + onNodeSelect={onNodeSelect} + onNodeContextMenu={onNodeContextMenu} + onGizmoInteractionStart={onGizmoInteractionStart} + onGizmoInteractionEnd={onGizmoInteractionEnd} + onNodePositionOffsetChange={onNodePositionOffsetChange} + onNodeRotationChange={onNodeRotationChange} + materialAssignments={materialAssignments} + /> )} 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 972d1e82..a93ba476 100644 --- a/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx +++ b/furniture-3d-system/src/components/platform/__tests__/AssemblyWorkbenchV2.test.tsx @@ -4,7 +4,10 @@ import { useState, type ReactElement, type ReactNode } from 'react' import { AssemblyWorkbenchV2 } from '../AssemblyWorkbenchV2' import { calculateAssemblyMaterialPanelTop } from '../AssemblyMaterialCard' import { + applyAutomaticAssemblySnapPreview, buildManualAssemblyWorkspace, + instantiateAssemblyNode, + updateAssemblyNodeTransform, type AssemblyWorkspaceSnapshot, } from '@/lib/open-product/assembly-workspace' @@ -61,6 +64,9 @@ jest.mock('../ModelPreviewPanel', () => { worldFrameMode, selectedNodeName, materialAssignments = [], + holes = [], + activeHolePanelIds = [], + highlightedNodeNames = [], overlayLeft, overlayRight, overlayBottomCenter, @@ -71,6 +77,9 @@ jest.mock('../ModelPreviewPanel', () => { worldFrameMode?: 'always' | 'initial' | 'never' selectedNodeName?: string | null materialAssignments?: Array<{ nodeName: string }> + holes?: Array<{ id: string; diameter?: number; depth?: number }> + activeHolePanelIds?: string[] + highlightedNodeNames?: string[] overlayLeft?: ReactNode overlayRight?: ReactNode overlayBottomCenter?: ReactNode @@ -99,6 +108,12 @@ jest.mock('../ModelPreviewPanel', () => {
{worldFrameMode ?? 'none'}
{selectedNodeName ?? 'none'}
材质指派:{materialAssignments.length}
+
孔位覆盖:{holes.length}
+
+ {holes[0] ? `${holes[0].diameter ?? 'none'}/${holes[0].depth ?? 'none'}` : 'none'} +
+
激活孔位面板:{activeHolePanelIds.length}
+
高亮节点:{highlightedNodeNames.length}
{withTestId(overlayLeft, 'material-zone')} {withTestId(overlayRight, 'right-drawer-zone')}
{overlayBottomCenter}
@@ -116,6 +131,21 @@ jest.mock('../ModelPreviewPanel', () => { > 模拟选中首节点 +