fix: close assisted assembly cad snap and prod deploy

This commit is contained in:
WuYongJie 2026-04-15 21:27:09 +08:00
parent 3158e87908
commit 69b1daf157
16 changed files with 2012 additions and 325 deletions

View File

@ -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<string, unknown> | null }>
} | null
initialFeedback?: { tone?: string; text?: string } | null
}) => (
<div data-testid="assisted-assembly-client">
@ -129,6 +132,9 @@ jest.mock('../assisted-assembly/AssistedAssemblyClient', () => ({
<div data-testid="launch-source-sku-id">{initialLaunchParams?.sourceSkuId ?? 'none'}</div>
<div data-testid="workspace-source-sku-id">{initialWorkspace?.sourceSkuId ?? 'none'}</div>
<div data-testid="workspace-first-node-id">{initialWorkspace?.nodes?.[0]?.nodeId ?? 'none'}</div>
<div data-testid="workspace-first-node-coordinate-system">
{String(initialWorkspace?.nodes?.[0]?.assemblyBasis?.coordinateSystem ?? 'none')}
</div>
<div data-testid="feedback-text">{initialFeedback?.text ?? 'none'}</div>
</div>
),
@ -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')
})
})

View File

@ -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<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
function mergeAssemblyBasisRecords(
previewNodeAssemblyBasis: unknown,
selectionAssemblyBasis: unknown,
): Record<string, unknown> | 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,

View File

@ -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<PanelInfo>(
scopedPanels.map((p) => ({
id: p.id,
code: p.code,

View File

@ -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<Panel>(
scopedPanels.map((p) => ({
id: p.id,
productId: p.productId,

View File

@ -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<string, string>,
panelLabelByPanelId: {} as Record<string, string>,
}
}
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}

View File

@ -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<string, string>
panelLabelByPanelId?: Record<string, string>
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}
/>
</div>
) : hasPreviewNodes ? (
@ -362,22 +387,23 @@ export function ModelPreviewPanel({
<ModelViewer
modelUrl={modelUrl!}
showSceneGrid={effectiveShowSceneGrid}
enableTranslateGizmo={enableTranslateGizmo}
gizmoMode={gizmoMode}
nodePositionOffsets={nodePositionOffsets}
nodeRotationStates={nodeRotationStates}
onEngineReady={handleEngineReady}
onModelNodesChange={onModelNodesChange}
selectableNodeNames={selectableNodeNames}
selectedNodeName={selectedNodeName}
onNodeSelect={onNodeSelect}
onNodeContextMenu={onNodeContextMenu}
onGizmoInteractionStart={onGizmoInteractionStart}
onGizmoInteractionEnd={onGizmoInteractionEnd}
onNodePositionOffsetChange={onNodePositionOffsetChange}
onNodeRotationChange={onNodeRotationChange}
materialAssignments={materialAssignments}
/>
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}
/>
</div>
)}
</div>

View File

@ -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', () => {
<div data-testid="world-frame-mode">{worldFrameMode ?? 'none'}</div>
<div data-testid="selected-node">{selectedNodeName ?? 'none'}</div>
<div data-testid="material-assignment-count">材质指派:{materialAssignments.length}</div>
<div data-testid="hole-overlay-count">孔位覆盖:{holes.length}</div>
<div data-testid="hole-overlay-first-metrics">
{holes[0] ? `${holes[0].diameter ?? 'none'}/${holes[0].depth ?? 'none'}` : 'none'}
</div>
<div data-testid="active-hole-panel-count">激活孔位面板:{activeHolePanelIds.length}</div>
<div data-testid="highlighted-node-count">高亮节点:{highlightedNodeNames.length}</div>
{withTestId(overlayLeft, 'material-zone')}
{withTestId(overlayRight, 'right-drawer-zone')}
<div>{overlayBottomCenter}</div>
@ -116,6 +131,21 @@ jest.mock('../ModelPreviewPanel', () => {
>
模拟选中首节点
</button>
<button
type="button"
onClick={() => {
const secondNode = worldNodes[1] ?? null
onNodeSelect?.({
nodeName: secondNode?.nodeName ?? null,
rawNodeName: secondNode?.nodeName ?? null,
candidateNodeName: secondNode?.nodeName ?? null,
appendToSelection: false,
hasRaycastHit: Boolean(secondNode),
})
}}
>
模拟选中第二节点
</button>
<button
type="button"
onClick={() =>
@ -217,8 +247,51 @@ function buildVariants() {
modelUrl: '/leg.glb',
thumbnailUrl: null,
dimensions: {},
mountPoints: [],
assemblyBasis: null,
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
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',
holeGroupType: 'pair',
holeGroupRole: 'mount',
surfaceFaces: ['bottom'],
},
],
holeGroups: [
{
id: 'leg-hole-group',
panelId: 'leg-panel',
groupType: 'pair',
groupRole: 'mount',
mountFace: 'bottom',
holeIds: ['leg-hole-1'],
surfaceFaces: ['bottom'],
},
],
mountPatterns: [
{
id: 'leg-pattern-1',
panelId: 'leg-panel',
mountFace: 'bottom',
patternType: 'single',
holeIds: ['leg-hole-1'],
requiredHoleIds: ['leg-hole-1'],
optionalHoleIds: [],
minRequiredHoles: 1,
preferredHoleCount: 1,
symmetry: false,
confidence: 0.98,
},
],
},
compatibilityRules: { allowedRoles: ['desktop'] },
} as never,
]
}
@ -228,11 +301,13 @@ const AVAILABLE_VARIANTS = buildVariants()
function ControlledWorkbench({
onSnapshotChange,
rightDrawerContent,
initialWorkspace,
}: {
onSnapshotChange?: (next: AssemblyWorkspaceSnapshot) => void
rightDrawerContent?: ReactNode
initialWorkspace?: AssemblyWorkspaceSnapshot
}) {
const [workspace, setWorkspace] = useState(() => buildWorkspace())
const [workspace, setWorkspace] = useState(() => initialWorkspace ?? buildWorkspace())
return (
<AssemblyWorkbenchV2
@ -251,6 +326,80 @@ function ControlledWorkbench({
)
}
function buildHoleSnapWorkspace() {
const baseWorkspace = buildWorkspace()
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-board-1:1',
variant: {
id: 'variant-board-1',
assetFamilyId: 'family-board-1',
assetType: 'BOARD',
code: 'BOARD-A-001',
name: '桌面A-001',
status: 'AVAILABLE',
modelUrl: '/board.glb',
thumbnailUrl: null,
dimensions: { length: 1200, width: 600, thickness: 25 },
mountPoints: [],
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',
holeGroupType: 'pair',
holeGroupRole: 'mount',
surfaceFaces: ['top'],
},
],
holeGroups: [
{
id: 'board-hole-group',
panelId: 'board-panel',
groupType: 'pair',
groupRole: 'mount',
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,
symmetry: false,
confidence: 0.98,
},
],
},
materialConfig: AVAILABLE_VARIANTS[0]?.materialConfig ?? null,
} as never,
})
const withLeg = instantiateAssemblyNode(withDesktop, {
inventoryId: 'leg:family-leg-1:2',
variant: AVAILABLE_VARIANTS[1] as never,
})
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
position: [0, 720, 0],
})
return applyAutomaticAssemblySnapPreview(moved, moved.nodes[1]!.nodeId, {
fallbackZone: 'staging',
})
}
describe('AssemblyWorkbenchV2', () => {
it('默认显示统一 3D 世界,并通过悬停展开左右浮层', async () => {
const actor = userEvent.setup()
@ -359,6 +508,29 @@ describe('AssemblyWorkbenchV2', () => {
expect(latestSnapshot.nodes).toHaveLength(0)
})
it('选中存在孔位候选的节点后,会把孔位覆盖层和目标节点高亮传给预览面板', async () => {
const actor = userEvent.setup()
render(
<ControlledWorkbench
initialWorkspace={buildHoleSnapWorkspace()}
rightDrawerContent={<div>右侧业务区</div>}
/>,
)
expect(screen.getByTestId('world-node-count')).toHaveTextContent('世界节点:2')
await actor.click(screen.getByRole('button', { name: '模拟选中第二节点' }))
await waitFor(() => {
expect(screen.getByTestId('selected-node')).not.toHaveTextContent('none')
expect(screen.getByTestId('hole-overlay-count')).toHaveTextContent('孔位覆盖:2')
expect(screen.getByTestId('hole-overlay-first-metrics')).toHaveTextContent('8/12')
expect(screen.getByTestId('active-hole-panel-count')).toHaveTextContent('激活孔位面板:2')
expect(screen.getByTestId('highlighted-node-count')).toHaveTextContent('高亮节点:1')
})
})
it('选中节点后不再显示底部快捷浮条,操作统一走右键菜单', async () => {
render(<ControlledWorkbench />)

View File

@ -22,12 +22,15 @@ export interface CADExportData {
modelFile: string
panelCount: number
units: string
coordinateSystem?: string
originOffset?: number[]
modelBounds?: number[]
// v2.0+ 字段
panels?: CADPanel[]
holes?: CADHole[]
holeGroups?: CADHoleGroup[]
mountPatterns?: CADMountPattern[]
/**
* 被过滤/排除的孔位(兜底用)。
*
@ -87,9 +90,41 @@ export interface CADHole {
holeGroupId?: string
holeGroupType?: string
holeGroupRole?: string
surfaceFaces?: string[]
suggestedFasteners?: CADSuggestedFastener[]
}
export interface CADHoleGroup {
id: string
panelId: string
groupType: string
holeIds: string[]
position: number[]
normal: number[]
mountFace?: string
confidence?: number
reason?: string
surfaceFaces?: string[]
warnings?: string[]
}
export interface CADMountPattern {
id: string
panelId: string
mountFace: string
patternType: string
holeIds: string[]
requiredHoleIds?: string[]
optionalHoleIds?: string[]
minRequiredHoles: number
preferredHoleCount: number
symmetry?: boolean
position: number[]
normal: number[]
confidence?: number
warnings?: string[]
}
export interface CADSuggestedFastener {
category: string
nominalDiameter?: number
@ -188,6 +223,7 @@ export interface ProcessedHole {
holeGroupId?: string
holeGroupType?: string
holeGroupRole?: string
surfaceFaces?: string[]
}
export interface ProcessedHardware {
@ -216,7 +252,10 @@ export interface ProcessingResult {
success: boolean
version: string
processingPath: 'direct' | 'fallback' | 'mixed'
coordinateSystem?: string
panels: ProcessedPanel[]
holeGroups: CADHoleGroup[]
mountPatterns: CADMountPattern[]
hardware: ProcessedHardware[]
statistics: {
totalPanels: number
@ -295,6 +334,7 @@ function mapPanelsDirectly(data: CADExportData): ProcessedPanel[] {
holeGroupId: hole.holeGroupId,
holeGroupType: hole.holeGroupType,
holeGroupRole: hole.holeGroupRole,
surfaceFaces: Array.isArray(hole.surfaceFaces) ? [...hole.surfaceFaces] : undefined,
})),
processingPath: 'direct' as const,
}))
@ -417,7 +457,10 @@ export function processCADExport(data: CADExportData): ProcessingResult {
success: false,
version,
processingPath: 'direct',
coordinateSystem: data.coordinateSystem,
panels: [],
holeGroups: [],
mountPatterns: [],
hardware: [],
statistics: {
totalPanels: 0,
@ -451,7 +494,10 @@ export function processCADExport(data: CADExportData): ProcessingResult {
success: true,
version,
processingPath,
coordinateSystem: data.coordinateSystem,
panels,
holeGroups: data.holeGroups ?? [],
mountPatterns: data.mountPatterns ?? [],
hardware,
statistics: {
totalPanels: panels.length,

View File

@ -2,6 +2,8 @@ import {
applyAutomaticAssemblySnapPreview,
attemptSnapNode,
buildManualAssemblyWorkspace,
deriveAssemblyActiveSnapCandidate,
deriveAssemblyAutoSnapCandidate,
deriveAssemblySaveValidation,
instantiateAssemblyNode,
moveAssemblyNodes,
@ -9,6 +11,7 @@ import {
syncManualAssemblyWorkspace,
updateAssemblyNodeTransform,
} from '../assembly-workspace'
import type { OpenProductAssetVariant } from '../types'
describe('assembly-workspace', () => {
const baseWorkspace = buildManualAssemblyWorkspace({
@ -36,6 +39,169 @@ describe('assembly-workspace', () => {
],
})
const desktopVariant: OpenProductAssetVariant = {
id: 'variant-desktop',
assetFamilyId: 'family-desktop',
code: 'BOARD-A-001',
name: '桌面A',
assetType: 'BOARD',
status: 'AVAILABLE',
modelUrl: '/board.glb',
thumbnailUrl: null,
dimensions: { length: 1200, width: 600, thickness: 25 },
mountPoints: [],
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',
holeGroupType: 'pair',
holeGroupRole: 'mount',
surfaceFaces: ['top'],
},
],
holeGroups: [
{
id: 'board-hole-group',
panelId: 'board-panel',
groupType: 'pair',
groupRole: 'mount',
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,
symmetry: false,
confidence: 0.98,
},
],
},
}
const legVariant: OpenProductAssetVariant = {
id: 'variant-leg',
assetFamilyId: 'family-leg',
code: 'LEG-A-001',
name: '桌腿A',
assetType: 'COMPONENT',
status: 'AVAILABLE',
modelUrl: '/leg.glb',
thumbnailUrl: null,
dimensions: { length: 80, width: 80, height: 700 },
mountPoints: [{ name: 'origin', position: [0, 0, 0] }],
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',
holeGroupType: 'pair',
holeGroupRole: 'mount',
surfaceFaces: ['bottom'],
},
],
holeGroups: [
{
id: 'leg-hole-group',
panelId: 'leg-panel',
groupType: 'pair',
groupRole: 'mount',
mountFace: 'bottom',
holeIds: ['leg-hole-1'],
surfaceFaces: ['bottom'],
},
],
mountPatterns: [
{
id: 'leg-pattern-1',
panelId: 'leg-panel',
mountFace: 'bottom',
patternType: 'single',
holeIds: ['leg-hole-1'],
requiredHoleIds: ['leg-hole-1'],
optionalHoleIds: [],
minRequiredHoles: 1,
preferredHoleCount: 1,
symmetry: false,
confidence: 0.98,
},
],
},
compatibilityRules: { allowedRoles: ['desktop'] },
}
const cadLegVariant: OpenProductAssetVariant = {
...legVariant,
id: 'variant-leg-cad',
code: 'LEG-A-CAD-001',
assemblyBasis: {
source: 'CAD',
attachTo: 'desktop',
holes: [
{
id: 'leg-hole-cad-1',
panelId: 'leg-panel',
position: [0, 15, 0],
normal: [0, 0, -1],
diameter: 8,
depth: 12,
holeGroupId: 'leg-hole-group',
holeGroupType: 'pair',
holeGroupRole: 'mount',
surfaceFaces: ['bottom'],
},
],
holeGroups: [
{
id: 'leg-hole-group',
panelId: 'leg-panel',
groupType: 'pair',
groupRole: 'mount',
mountFace: 'bottom',
holeIds: ['leg-hole-cad-1'],
surfaceFaces: ['bottom'],
},
],
mountPatterns: [
{
id: 'leg-pattern-cad-1',
panelId: 'leg-panel',
mountFace: 'bottom',
patternType: 'single',
holeIds: ['leg-hole-cad-1'],
requiredHoleIds: ['leg-hole-cad-1'],
optionalHoleIds: [],
minRequiredHoles: 1,
preferredHoleCount: 1,
symmetry: false,
confidence: 0.98,
},
],
},
}
it('会先以资产族库存打开工作台,而不是直接生成候选节点', () => {
expect(baseWorkspace.inventory).toHaveLength(2)
expect(baseWorkspace.nodes).toHaveLength(0)
@ -330,4 +496,78 @@ describe('assembly-workspace', () => {
expect(synced.nodes).toHaveLength(1)
expect(synced.nodes[0]?.assetFamilyId).toBe('family-desktop')
})
it('mountPattern / holeGroup 会先生成预吸附绑定,再在松手后固化 snapStatus 与 snapBinding', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
variant: desktopVariant,
})
const withLeg = instantiateAssemblyNode(withDesktop, {
inventoryId: 'leg:family-leg:2',
variant: legVariant,
})
const moved = updateAssemblyNodeTransform(withLeg, withLeg.nodes[1]!.nodeId, {
position: [0, 720, 0],
})
const liveCandidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId)
expect(liveCandidate).not.toBeNull()
expect(liveCandidate?.sourceMountPatternId).toBe('leg-pattern-1')
expect(liveCandidate?.targetMountPatternId).toBe('board-pattern-1')
expect(liveCandidate?.sourceHoleId).toBe('leg-hole-1')
expect(liveCandidate?.targetHoleId).toBe('board-hole-1')
expect(liveCandidate?.sourceHoleDiameter).toBe(8)
expect(liveCandidate?.targetHoleDepth).toBe(12)
const preview = applyAutomaticAssemblySnapPreview(moved, moved.nodes[1]!.nodeId, {
fallbackZone: 'staging',
})
expect(preview.nodes[1]?.zone).toBe('main')
expect(preview.nodes[1]?.isSnapped).toBe(false)
expect(preview.nodes[1]?.snapStatus).toBe('hole-preview')
expect(preview.nodes[1]?.snapBinding).toMatchObject({
targetNodeId: withDesktop.nodes[0]?.nodeId,
sourceHoleId: 'leg-hole-1',
targetHoleId: 'board-hole-1',
sourceMountPatternId: 'leg-pattern-1',
targetMountPatternId: 'board-pattern-1',
})
const snapped = attemptSnapNode(preview, preview.nodes[1]!.nodeId)
const activeCandidate = deriveAssemblyActiveSnapCandidate(snapped, snapped.nodes[1]!.nodeId)
expect(snapped.nodes[1]?.isSnapped).toBe(true)
expect(snapped.nodes[1]?.snapStatus).toBe('hole-snap')
expect(snapped.nodes[1]?.snapBinding).toMatchObject({
targetNodeId: withDesktop.nodes[0]?.nodeId,
sourceHoleId: 'leg-hole-1',
targetHoleId: 'board-hole-1',
})
expect(activeCandidate).not.toBeNull()
expect(activeCandidate?.targetNodeId).toBe(withDesktop.nodes[0]?.nodeId)
})
it('CAD 坐标系孔位会先转成 Three.js 坐标,再参与孔位吸附候选', () => {
const withDesktop = instantiateAssemblyNode(baseWorkspace, {
inventoryId: 'desktop:family-desktop:1',
variant: desktopVariant,
})
const withCadLeg = instantiateAssemblyNode(withDesktop, {
inventoryId: 'leg:family-leg:2',
variant: cadLegVariant,
})
const moved = updateAssemblyNodeTransform(withCadLeg, withCadLeg.nodes[1]!.nodeId, {
position: [0, 720, -15],
})
const candidate = deriveAssemblyAutoSnapCandidate(moved, moved.nodes[1]!.nodeId)
expect(candidate).not.toBeNull()
expect(candidate?.sourceHoleId).toBe('leg-hole-cad-1')
expect(candidate?.targetHoleId).toBe('board-hole-1')
expect(candidate?.sourceMountPatternId).toBe('leg-pattern-cad-1')
expect(candidate?.targetMountPatternId).toBe('board-pattern-1')
})
})

View File

@ -1,3 +1,4 @@
import * as THREE from 'three'
import type {
OpenProductAssetFamily,
OpenProductAssetType,
@ -18,6 +19,7 @@ export type AssemblyNodeSource = 'desktop' | 'leg' | 'connector' | 'unknown'
export type AssemblyRelationType = 'hole-group' | 'surface' | 'connector'
export type AssemblyRelationStatus = 'CONNECTED' | 'SUGGESTED' | 'PENDING'
export type AssemblySnapStatus = 'hole-preview' | 'hole-snap' | 'manual-unverified'
export interface WeakSlotAssignment {
role: string
@ -57,12 +59,27 @@ export interface AssemblyWorkspaceNode extends PreSkuPreviewNode {
targetPosition: [number, number, number] | null
targetRotation: [number, number, number] | null
isSnapped: boolean
snapStatus?: AssemblySnapStatus | null
subassemblyId: string | null
dimensions?: Record<string, number> | null
snapBinding?: AssemblySnapBinding | null
mountPoints: OpenProductMountPoint[]
assemblyBasis: Record<string, unknown> | null
compatibilityRules?: Record<string, unknown> | null
}
export interface AssemblySnapBinding {
targetNodeId: string
sourceHoleId: string
targetHoleId: string
sourceHolePanelId: string | null
targetHolePanelId: string | null
sourceFace: string | null
targetFace: string | null
sourceMountPatternId: string | null
targetMountPatternId: string | null
}
export interface AssemblySubassembly {
id: string
name: string
@ -91,6 +108,34 @@ export interface AssemblyValidationResult {
warnings: string[]
}
export interface AssemblyAutoSnapCandidate {
sourceNodeId: string
targetNodeId: string
sourceMountPatternId: string
targetMountPatternId: string
sourceHoleId: string
targetHoleId: string
sourceHolePanelId: string | null
targetHolePanelId: string | null
sourceHoleDiameter: number | null
targetHoleDiameter: number | null
sourceHoleDepth: number | null
targetHoleDepth: number | null
sourceFace: string | null
targetFace: string | null
matchedHoleCount: number
requiredHoleCount: number
previewPosition: [number, number, number]
previewRotation: [number, number, number]
sourceHoleWorldPosition: [number, number, number]
targetHoleWorldPosition: [number, number, number]
sourceHoleNormal: [number, number, number]
targetHoleNormal: [number, number, number]
distanceMm: number
isValid: boolean
rejectReason: string | null
}
export interface AssemblyWorkspaceSnapshot {
version: '2026-04-13' | '2026-04-09' | '2026-04-08'
productCategoryId?: string | null
@ -155,6 +200,9 @@ interface BuildSingleBoardDeskAssemblyWorkspaceInput {
}
const SNAP_DISTANCE_THRESHOLD = 180
const HOLE_SNAP_DISTANCE_THRESHOLD_MM = 120
const HOLE_DIAMETER_TOLERANCE_MM = 2
const HOLE_NORMAL_ALIGNMENT_DOT_MAX = -0.45
const DEFAULT_DESKTOP_POSITION: [number, number, number] = [0, 720, 0]
@ -172,6 +220,45 @@ const DEFAULT_CONNECTOR_TARGET_OFFSETS: Array<[number, number, number]> = [
[220, -110, -220],
]
interface AssemblyBasisHoleGroupRecord {
id: string
panelId: string | null
mountFace: string | null
surfaceFaces: string[]
}
interface AssemblyBasisHoleRecord {
id: string
panelId: string | null
position: [number, number, number]
normal: [number, number, number]
diameter: number | null
depth: number | null
holeType: string | null
groupId: string | null
surfaceFaces: string[]
}
interface AssemblyBasisMountPatternRecord {
id: string
panelId: string | null
mountFace: string | null
holeIds: string[]
minRequiredHoles: number
}
interface AssemblyMatchedHolePair {
sourceHole: AssemblyBasisHoleRecord
targetHole: AssemblyBasisHoleRecord
sourceHoleWorldPosition: [number, number, number]
targetHoleWorldPosition: [number, number, number]
sourceHoleNormal: [number, number, number]
targetHoleNormal: [number, number, number]
distanceMm: number
sourceFace: string | null
targetFace: string | null
}
function cloneVector3(
input: [number, number, number] | undefined | null,
): [number, number, number] {
@ -213,6 +300,570 @@ function readStringValue(record: Record<string, unknown> | null | undefined, key
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
function readNumberValue(record: Record<string, unknown> | null | undefined, key: string) {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function readRecordArray(record: Record<string, unknown> | null | undefined, key: string) {
const value = record?.[key]
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: []
}
function readVector3Tuple(value: unknown): [number, number, number] | null {
if (!Array.isArray(value) || value.length < 3) {
return null
}
const x = typeof value[0] === 'number' && Number.isFinite(value[0]) ? value[0] : null
const y = typeof value[1] === 'number' && Number.isFinite(value[1]) ? value[1] : null
const z = typeof value[2] === 'number' && Number.isFinite(value[2]) ? value[2] : null
if (x === null || y === null || z === null) {
return null
}
return [x, y, z]
}
function normalizeVector3Tuple(
value: [number, number, number],
fallback: [number, number, number],
): [number, number, number] {
const vector = new THREE.Vector3(value[0], value[1], value[2])
if (vector.lengthSq() <= 1e-6) {
return fallback
}
vector.normalize()
return [vector.x, vector.y, vector.z]
}
function transformCadVector3Tuple(value: [number, number, number]): [number, number, number] {
return [value[0], -value[2], value[1]]
}
function shouldApplyCadHoleTransform(node: AssemblyWorkspaceNode) {
const sourceHints = [
readStringValue(node.assemblyBasis, 'source'),
readStringValue(node.assemblyBasis, 'sourceType'),
readStringValue(node.assemblyBasis, 'coordinateSystem'),
readStringValue(node.assemblyBasis, 'generatedFrom'),
]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase())
return sourceHints.some(
(value) =>
value.includes('cad') ||
value.includes('z-up') ||
value.includes('zup') ||
value.includes('solidworks') ||
value.includes('inventor'),
)
}
function getOppositeSurfaceFace(face: string) {
switch (face) {
case 'left':
return 'right'
case 'right':
return 'left'
case 'front':
return 'back'
case 'back':
return 'front'
case 'top':
return 'bottom'
case 'bottom':
return 'top'
default:
return null
}
}
function normalizeSurfaceFaces(value: unknown): string[] {
return readStringList(value).map((item) => item.trim().toLowerCase())
}
function resolveCompatibleFacePair(sourceFaces: string[], targetFaces: string[]) {
if (sourceFaces.length === 0 || targetFaces.length === 0) {
return {
sourceFace: sourceFaces[0] ?? null,
targetFace: targetFaces[0] ?? null,
}
}
const targetSet = new Set(targetFaces)
for (const sourceFace of sourceFaces) {
const oppositeFace = getOppositeSurfaceFace(sourceFace)
if (oppositeFace && targetSet.has(oppositeFace)) {
return {
sourceFace,
targetFace: oppositeFace,
}
}
}
return null
}
function getNodeQuaternion(rotation: [number, number, number]) {
return new THREE.Quaternion().setFromEuler(new THREE.Euler(rotation[0], rotation[1], rotation[2], 'XYZ'))
}
function getScaledVector(vector: [number, number, number], scale: [number, number, number]) {
return new THREE.Vector3(vector[0] * scale[0], vector[1] * scale[1], vector[2] * scale[2])
}
function tupleFromVector(vector: THREE.Vector3): [number, number, number] {
return [vector.x, vector.y, vector.z]
}
function getNodeWorldPoint(
node: Pick<AssemblyWorkspaceNode, 'position' | 'rotation' | 'scale'>,
localPosition: [number, number, number],
): THREE.Vector3 {
const quaternion = getNodeQuaternion(node.rotation)
return getScaledVector(localPosition, node.scale)
.applyQuaternion(quaternion)
.add(new THREE.Vector3(node.position[0], node.position[1], node.position[2]))
}
function getNodeWorldDirection(
node: Pick<AssemblyWorkspaceNode, 'rotation'>,
localDirection: [number, number, number],
): THREE.Vector3 {
const quaternion = getNodeQuaternion(node.rotation)
return new THREE.Vector3(localDirection[0], localDirection[1], localDirection[2])
.normalize()
.applyQuaternion(quaternion)
.normalize()
}
function buildAssemblyHoleGroups(node: AssemblyWorkspaceNode) {
return new Map(
readRecordArray(node.assemblyBasis, 'holeGroups')
.map<AssemblyBasisHoleGroupRecord | null>((record) => {
const id = readStringValue(record, 'id')
if (!id) return null
return {
id,
panelId: readStringValue(record, 'panelId'),
mountFace: readStringValue(record, 'mountFace'),
surfaceFaces: normalizeSurfaceFaces(record.surfaceFaces),
}
})
.filter((item): item is AssemblyBasisHoleGroupRecord => item !== null)
.map((item) => [item.id, item]),
)
}
function buildAssemblyMountPatterns(node: AssemblyWorkspaceNode): AssemblyBasisMountPatternRecord[] {
return readRecordArray(node.assemblyBasis, 'mountPatterns')
.map<AssemblyBasisMountPatternRecord | null>((record) => {
const id = readStringValue(record, 'id')
const holeIds = readStringList(record.holeIds)
if (!id || holeIds.length === 0) {
return null
}
const requestedMinRequired =
readNumberValue(record, 'minRequiredHoles') ??
readStringList(record.requiredHoleIds).length ??
holeIds.length
return {
id,
panelId: readStringValue(record, 'panelId'),
mountFace: readStringValue(record, 'mountFace'),
holeIds,
minRequiredHoles: Math.max(1, Math.min(holeIds.length, Math.round(requestedMinRequired))),
}
})
.filter((item): item is AssemblyBasisMountPatternRecord => item !== null)
}
function getNodeAssemblyMountPatterns(node: AssemblyWorkspaceNode) {
return buildAssemblyMountPatterns(node)
}
function getNodeAssemblyHoles(node: AssemblyWorkspaceNode): AssemblyBasisHoleRecord[] {
const groupById = buildAssemblyHoleGroups(node)
const applyCadTransform = shouldApplyCadHoleTransform(node)
return readRecordArray(node.assemblyBasis, 'holes')
.map<AssemblyBasisHoleRecord | null>((record) => {
const id = readStringValue(record, 'id')
const rawPosition = readVector3Tuple(record.position)
const rawNormal = readVector3Tuple(record.normal)
if (!id || !rawPosition || !rawNormal) {
return null
}
const groupId =
readStringValue(record, 'groupId') ??
readStringValue(record, 'holeGroupId')
const group = groupId ? groupById.get(groupId) ?? null : null
const position = applyCadTransform ? transformCadVector3Tuple(rawPosition) : rawPosition
const normal = applyCadTransform ? transformCadVector3Tuple(rawNormal) : rawNormal
return {
id,
panelId: readStringValue(record, 'panelId') ?? group?.panelId ?? null,
position,
normal: normalizeVector3Tuple(normal, [0, 1, 0]),
diameter: readNumberValue(record, 'diameter'),
depth: readNumberValue(record, 'depth'),
holeType: readStringValue(record, 'holeType') ?? readStringValue(record, 'type'),
groupId,
surfaceFaces: normalizeSurfaceFaces(record.surfaceFaces ?? group?.surfaceFaces),
}
})
.filter((item): item is AssemblyBasisHoleRecord => item !== null)
}
function canUseHoleAutoSnap(node: AssemblyWorkspaceNode) {
return getNodeAssemblyMountPatterns(node).length > 0 && getNodeAssemblyHoles(node).length > 0
}
function areHoleDiametersCompatible(
sourceHole: AssemblyBasisHoleRecord,
targetHole: AssemblyBasisHoleRecord,
) {
if (sourceHole.diameter === null || targetHole.diameter === null) {
return true
}
return Math.abs(sourceHole.diameter - targetHole.diameter) <= HOLE_DIAMETER_TOLERANCE_MM
}
function resolveHoleFacePair(
sourceHole: AssemblyBasisHoleRecord,
targetHole: AssemblyBasisHoleRecord,
sourcePattern: AssemblyBasisMountPatternRecord,
targetPattern: AssemblyBasisMountPatternRecord,
) {
const sourceFaces =
sourceHole.surfaceFaces.length > 0
? sourceHole.surfaceFaces
: sourcePattern.mountFace
? [sourcePattern.mountFace]
: []
const targetFaces =
targetHole.surfaceFaces.length > 0
? targetHole.surfaceFaces
: targetPattern.mountFace
? [targetPattern.mountFace]
: []
return resolveCompatibleFacePair(sourceFaces, targetFaces)
}
function buildSnapBindingFromCandidate(candidate: AssemblyAutoSnapCandidate): AssemblySnapBinding {
return {
targetNodeId: candidate.targetNodeId,
sourceHoleId: candidate.sourceHoleId,
targetHoleId: candidate.targetHoleId,
sourceHolePanelId: candidate.sourceHolePanelId,
targetHolePanelId: candidate.targetHolePanelId,
sourceFace: candidate.sourceFace,
targetFace: candidate.targetFace,
sourceMountPatternId: candidate.sourceMountPatternId,
targetMountPatternId: candidate.targetMountPatternId,
}
}
function resolveSnapBindingPattern(
node: AssemblyWorkspaceNode,
holeId: string,
preferredPatternId: string | null,
) {
const patterns = getNodeAssemblyMountPatterns(node)
if (preferredPatternId) {
const preferredPattern = patterns.find((pattern) => pattern.id === preferredPatternId) ?? null
if (preferredPattern) {
return preferredPattern
}
}
return patterns.find((pattern) => pattern.holeIds.includes(holeId)) ?? null
}
function buildSnapBindingDisplayCandidate(
snapshot: AssemblyWorkspaceSnapshot,
sourceNode: AssemblyWorkspaceNode,
): AssemblyAutoSnapCandidate | null {
const binding = sourceNode.snapBinding
if (!binding?.targetNodeId || !binding.sourceHoleId || !binding.targetHoleId) {
return null
}
const targetNode = snapshot.nodes.find((node) => node.nodeId === binding.targetNodeId) ?? null
if (!targetNode) {
return null
}
const sourceHole = getNodeAssemblyHoles(sourceNode).find((hole) => hole.id === binding.sourceHoleId) ?? null
const targetHole = getNodeAssemblyHoles(targetNode).find((hole) => hole.id === binding.targetHoleId) ?? null
if (!sourceHole || !targetHole) {
return null
}
const sourcePattern = resolveSnapBindingPattern(
sourceNode,
sourceHole.id,
binding.sourceMountPatternId ?? null,
)
const targetPattern = resolveSnapBindingPattern(
targetNode,
targetHole.id,
binding.targetMountPatternId ?? null,
)
const sourceHoleWorldPosition = getNodeWorldPoint(sourceNode, sourceHole.position)
const targetHoleWorldPosition = getNodeWorldPoint(targetNode, targetHole.position)
return {
sourceNodeId: sourceNode.nodeId,
targetNodeId: targetNode.nodeId,
sourceMountPatternId: sourcePattern?.id ?? binding.sourceMountPatternId ?? sourceHole.id,
targetMountPatternId: targetPattern?.id ?? binding.targetMountPatternId ?? targetHole.id,
sourceHoleId: sourceHole.id,
targetHoleId: targetHole.id,
sourceHolePanelId: binding.sourceHolePanelId ?? sourceHole.panelId,
targetHolePanelId: binding.targetHolePanelId ?? targetHole.panelId,
sourceHoleDiameter: sourceHole.diameter,
targetHoleDiameter: targetHole.diameter,
sourceHoleDepth: sourceHole.depth,
targetHoleDepth: targetHole.depth,
sourceFace: binding.sourceFace ?? null,
targetFace: binding.targetFace ?? null,
matchedHoleCount: 1,
requiredHoleCount: Math.max(
sourcePattern?.minRequiredHoles ?? 1,
targetPattern?.minRequiredHoles ?? 1,
),
previewPosition: cloneVector3(sourceNode.position),
previewRotation: cloneVector3(sourceNode.rotation),
sourceHoleWorldPosition: tupleFromVector(sourceHoleWorldPosition),
targetHoleWorldPosition: tupleFromVector(targetHoleWorldPosition),
sourceHoleNormal: tupleFromVector(getNodeWorldDirection(sourceNode, sourceHole.normal)),
targetHoleNormal: tupleFromVector(getNodeWorldDirection(targetNode, targetHole.normal)),
distanceMm: sourceHoleWorldPosition.distanceTo(targetHoleWorldPosition),
isValid: true,
rejectReason: null,
}
}
export function deriveAssemblyActiveSnapCandidate(
snapshot: AssemblyWorkspaceSnapshot,
nodeId: string,
): AssemblyAutoSnapCandidate | null {
const liveCandidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
if (liveCandidate) {
return liveCandidate
}
const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
if (!sourceNode?.snapBinding) {
return null
}
return buildSnapBindingDisplayCandidate(snapshot, sourceNode)
}
export function deriveAssemblyAutoSnapCandidate(
snapshot: AssemblyWorkspaceSnapshot,
nodeId: string,
): AssemblyAutoSnapCandidate | null {
const sourceNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
if (!sourceNode || sourceNode.source === 'desktop' || !canUseHoleAutoSnap(sourceNode)) {
return null
}
const sourcePatterns = getNodeAssemblyMountPatterns(sourceNode)
const sourceHoleMap = new Map(getNodeAssemblyHoles(sourceNode).map((hole) => [hole.id, hole]))
let bestCandidate: AssemblyAutoSnapCandidate | null = null
snapshot.nodes.forEach((targetNode) => {
if (
targetNode.nodeId === sourceNode.nodeId ||
targetNode.zone !== 'main' ||
!canUseHoleAutoSnap(targetNode) ||
!matchesPrimaryAnchorRole(sourceNode, targetNode)
) {
return
}
const targetPatterns = getNodeAssemblyMountPatterns(targetNode)
const targetHoleMap = new Map(getNodeAssemblyHoles(targetNode).map((hole) => [hole.id, hole]))
sourcePatterns.forEach((sourcePattern) => {
const sourcePatternHoles = sourcePattern.holeIds
.map((holeId) => sourceHoleMap.get(holeId) ?? null)
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
if (sourcePatternHoles.length === 0) {
return
}
targetPatterns.forEach((targetPattern) => {
const targetPatternHoles = targetPattern.holeIds
.map((holeId) => targetHoleMap.get(holeId) ?? null)
.filter((hole): hole is AssemblyBasisHoleRecord => hole !== null)
if (targetPatternHoles.length === 0) {
return
}
sourcePatternHoles.forEach((anchorSourceHole) => {
targetPatternHoles.forEach((anchorTargetHole) => {
if (!areHoleDiametersCompatible(anchorSourceHole, anchorTargetHole)) {
return
}
const anchorFacePair = resolveHoleFacePair(
anchorSourceHole,
anchorTargetHole,
sourcePattern,
targetPattern,
)
if (!anchorFacePair) {
return
}
const anchorSourceWorld = getNodeWorldPoint(sourceNode, anchorSourceHole.position)
const anchorTargetWorld = getNodeWorldPoint(targetNode, anchorTargetHole.position)
const anchorSourceNormal = getNodeWorldDirection(sourceNode, anchorSourceHole.normal)
const anchorTargetNormal = getNodeWorldDirection(targetNode, anchorTargetHole.normal)
const anchorNormalDot = anchorSourceNormal.dot(anchorTargetNormal)
if (anchorNormalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) {
return
}
const translation = anchorTargetWorld.clone().sub(anchorSourceWorld)
const matchedPairs: AssemblyMatchedHolePair[] = []
const usedSourceHoleIds = new Set<string>()
const usedTargetHoleIds = new Set<string>()
sourcePatternHoles.forEach((sourceHole) => {
let bestPair: AssemblyMatchedHolePair | null = null
targetPatternHoles.forEach((targetHole) => {
if (usedTargetHoleIds.has(targetHole.id) || !areHoleDiametersCompatible(sourceHole, targetHole)) {
return
}
const facePair = resolveHoleFacePair(sourceHole, targetHole, sourcePattern, targetPattern)
if (!facePair) {
return
}
const translatedSourceWorld = getNodeWorldPoint(sourceNode, sourceHole.position).add(translation)
const targetHoleWorld = getNodeWorldPoint(targetNode, targetHole.position)
const sourceHoleNormal = getNodeWorldDirection(sourceNode, sourceHole.normal)
const targetHoleNormal = getNodeWorldDirection(targetNode, targetHole.normal)
const normalDot = sourceHoleNormal.dot(targetHoleNormal)
if (normalDot > HOLE_NORMAL_ALIGNMENT_DOT_MAX) {
return
}
const distanceMm = translatedSourceWorld.distanceTo(targetHoleWorld)
if (distanceMm > HOLE_SNAP_DISTANCE_THRESHOLD_MM) {
return
}
const candidatePair: AssemblyMatchedHolePair = {
sourceHole,
targetHole,
sourceHoleWorldPosition: tupleFromVector(translatedSourceWorld),
targetHoleWorldPosition: tupleFromVector(targetHoleWorld),
sourceHoleNormal: tupleFromVector(sourceHoleNormal),
targetHoleNormal: tupleFromVector(targetHoleNormal),
distanceMm,
sourceFace: facePair.sourceFace,
targetFace: facePair.targetFace,
}
if (!bestPair || candidatePair.distanceMm < bestPair.distanceMm) {
bestPair = candidatePair
}
})
const resolvedBestPair = bestPair as AssemblyMatchedHolePair | null
if (resolvedBestPair) {
usedSourceHoleIds.add(resolvedBestPair.sourceHole.id)
usedTargetHoleIds.add(resolvedBestPair.targetHole.id)
matchedPairs.push(resolvedBestPair)
}
})
const requiredHoleCount = Math.max(
1,
sourcePattern.minRequiredHoles,
targetPattern.minRequiredHoles,
)
if (matchedPairs.length < requiredHoleCount) {
return
}
const averageDistance =
matchedPairs.reduce((sum, pair) => sum + pair.distanceMm, 0) / matchedPairs.length
const previewPosition = tupleFromVector(
new THREE.Vector3(sourceNode.position[0], sourceNode.position[1], sourceNode.position[2]).add(
translation,
),
)
const firstPair = matchedPairs[0]!
const candidate: AssemblyAutoSnapCandidate = {
sourceNodeId: sourceNode.nodeId,
targetNodeId: targetNode.nodeId,
sourceMountPatternId: sourcePattern.id,
targetMountPatternId: targetPattern.id,
sourceHoleId: firstPair.sourceHole.id,
targetHoleId: firstPair.targetHole.id,
sourceHolePanelId: firstPair.sourceHole.panelId,
targetHolePanelId: firstPair.targetHole.panelId,
sourceHoleDiameter: firstPair.sourceHole.diameter,
targetHoleDiameter: firstPair.targetHole.diameter,
sourceHoleDepth: firstPair.sourceHole.depth,
targetHoleDepth: firstPair.targetHole.depth,
sourceFace: firstPair.sourceFace,
targetFace: firstPair.targetFace,
matchedHoleCount: matchedPairs.length,
requiredHoleCount,
previewPosition,
previewRotation: cloneVector3(sourceNode.rotation),
sourceHoleWorldPosition: firstPair.sourceHoleWorldPosition,
targetHoleWorldPosition: firstPair.targetHoleWorldPosition,
sourceHoleNormal: firstPair.sourceHoleNormal,
targetHoleNormal: firstPair.targetHoleNormal,
distanceMm: averageDistance,
isValid: true,
rejectReason: null,
}
if (
!bestCandidate ||
candidate.matchedHoleCount > bestCandidate.matchedHoleCount ||
(candidate.matchedHoleCount === bestCandidate.matchedHoleCount &&
candidate.distanceMm < bestCandidate.distanceMm)
) {
bestCandidate = candidate
}
})
})
})
})
})
return bestCandidate
}
function buildInventoryId(source: Exclude<AssemblyNodeSource, 'unknown'>, familyId: string, index: number) {
return `${source}:${familyId}:${index + 1}`
}
@ -693,7 +1344,10 @@ export function buildSingleBoardDeskAssemblyWorkspace(
targetPosition: source === 'desktop' ? cloneVector3(node.position) : null,
targetRotation: cloneVector3(node.rotation),
isSnapped: source === 'desktop',
snapStatus: source === 'desktop' ? 'hole-snap' : null,
subassemblyId: null,
dimensions: null,
snapBinding: null,
mountPoints: slotSelection?.variant.mountPoints ?? [],
assemblyBasis: slotSelection?.variant.assemblyBasis ?? null,
compatibilityRules: null,
@ -843,7 +1497,7 @@ export function instantiateAssemblyNode(
: buildStagingPosition(snapshot.nodes.length, inventoryItem.source)
const baseRole = isDesktop ? 'desktop' : inventoryItem.source
const nextNodes = [
const nextNodes: AssemblyWorkspaceNode[] = [
...snapshot.nodes,
{
nodeId: buildNodeId(input.inventoryId, input.variant.id, instanceIndex, snapshot.nodes.length),
@ -872,7 +1526,10 @@ export function instantiateAssemblyNode(
targetPosition: isDesktop ? cloneVector3(DEFAULT_DESKTOP_POSITION) : null,
targetRotation: [0, 0, 0] as [number, number, number],
isSnapped: isDesktop && nodeZone === 'main',
snapStatus: (isDesktop && nodeZone === 'main' ? 'hole-snap' : null) as AssemblySnapStatus | null,
subassemblyId: null,
dimensions: input.variant.dimensions ?? null,
snapBinding: null,
mountPoints: input.variant.mountPoints ?? [],
assemblyBasis: input.variant.assemblyBasis ?? null,
compatibilityRules: input.variant.compatibilityRules ?? null,
@ -909,6 +1566,8 @@ export function moveAssemblyNodes(
zone,
subassemblyId: zone === 'subassembly' ? node.subassemblyId : null,
isSnapped: zone === 'main' ? (isDesktop ? true : false) : false,
snapStatus: zone === 'main' ? (isDesktop ? 'hole-snap' : 'manual-unverified') : null,
snapBinding: zone === 'main' && isDesktop ? node.snapBinding ?? null : null,
roleConfirmed: zone === 'main' && isDesktop ? 'desktop' : node.roleConfirmed,
}
})
@ -931,6 +1590,13 @@ export function updateAssemblyNodeTransform(
position: transform.position ? cloneVector3(transform.position) : cloneVector3(node.position),
rotation: transform.rotation ? cloneVector3(transform.rotation) : cloneVector3(node.rotation),
isSnapped: node.zone === 'main' && node.source !== 'desktop' ? false : node.isSnapped,
snapStatus:
node.source === 'desktop'
? 'hole-snap'
: node.zone === 'main'
? 'manual-unverified'
: null,
snapBinding: node.source === 'desktop' ? node.snapBinding ?? null : null,
}
: node,
)
@ -974,6 +1640,7 @@ export function snapNodesIntoMainAssembly(
position: cloneVector3(node.targetPosition),
rotation: node.targetRotation ? cloneVector3(node.targetRotation) : cloneVector3(node.rotation),
isSnapped: true,
snapStatus: 'hole-snap',
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
}
: node,
@ -986,13 +1653,48 @@ export function attemptSnapNode(
snapshot: AssemblyWorkspaceSnapshot,
nodeId: string,
): AssemblyWorkspaceSnapshot {
const targetNode = snapshot.nodes.find((node) => node.nodeId === nodeId)
if (!targetNode || !isNodeCloseEnoughToSnap(targetNode)) {
const currentNode = snapshot.nodes.find((node) => node.nodeId === nodeId) ?? null
if (currentNode?.zone === 'main' && currentNode.snapBinding && !currentNode.isSnapped) {
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
node.nodeId === nodeId
? {
...node,
isSnapped: true,
snapStatus: 'hole-snap',
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
}
: node,
)
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
}
const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
if (candidate?.isValid) {
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
node.nodeId === nodeId
? {
...node,
zone: 'main',
position: cloneVector3(candidate.previewPosition),
rotation: cloneVector3(candidate.previewRotation),
isSnapped: true,
snapStatus: 'hole-snap',
snapBinding: buildSnapBindingFromCandidate(candidate),
roleConfirmed: node.roleConfirmed ?? node.roleRecommendation,
}
: node,
)
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
}
if (!currentNode || !isNodeCloseEnoughToSnap(currentNode)) {
return snapshot
}
const readyForSnap =
targetNode.zone === 'main'
currentNode.zone === 'main'
? snapshot
: moveAssemblyNodes(snapshot, [nodeId], 'main')
@ -1012,6 +1714,26 @@ export function applyAutomaticAssemblySnapPreview(
}
const fallbackZone = options.fallbackZone ?? targetNode.zone
const candidate = deriveAssemblyAutoSnapCandidate(snapshot, nodeId)
if (candidate?.isValid) {
const nextNodes = snapshot.nodes.map<AssemblyWorkspaceNode>((node) =>
node.nodeId === nodeId
? {
...node,
zone: 'main',
position: cloneVector3(candidate.previewPosition),
rotation: cloneVector3(candidate.previewRotation),
isSnapped: false,
snapStatus: 'hole-preview',
snapBinding: buildSnapBindingFromCandidate(candidate),
}
: node,
)
return rebuildWorkspaceSnapshot(snapshot, nextNodes)
}
const primaryAnchorNode = resolvePrimaryAnchorNode(snapshot.nodes)
const shouldSnap =
canAutoSnapNodeToPrimaryAnchor(targetNode, primaryAnchorNode) &&
@ -1035,6 +1757,8 @@ export function applyAutomaticAssemblySnapPreview(
...node,
zone: fallbackZone,
isSnapped: false,
snapStatus: fallbackZone === 'main' ? 'manual-unverified' : null,
snapBinding: null,
}
: node,
)

View File

@ -12,6 +12,17 @@ export function getAssistedAssemblyVariantMaterialConfig(
return variant?.materialConfig ?? null
}
export function resolveAssistedAssemblyNodeMaterialConfig(options: {
variant?: AssistedAssemblyVariant | null
nodeMaterialConfig?: MaterialConfig | null
}): MaterialConfig | null {
return (
getAssistedAssemblyVariantMaterialConfig(options.variant) ??
options.nodeMaterialConfig ??
null
)
}
export function getAssistedAssemblyVariantThumbnailUrl(
variant: AssistedAssemblyVariant | null | undefined,
): string | null {

View File

@ -1,4 +1,5 @@
import type { MaterialConfig } from '@/lib/3d/material-manager'
import type { AssetVariantUvConfig } from '@/lib/asset-variant-render-config'
export type OpenProductAssetType = 'BOARD' | 'COMPONENT' | 'HARDWARE' | 'PACKAGING'
@ -38,6 +39,7 @@ export interface OpenProductAssetVariant {
mountPoints: OpenProductMountPoint[]
substrateId?: string | null
finishId?: string | null
uvConfig?: AssetVariantUvConfig | null
weightKg?: number | null
costPrice?: number | null
assemblyBasis?: Record<string, unknown> | null

View File

@ -39,6 +39,7 @@ export interface PersistedSkuCadHole {
holeGroupId?: string
holeGroupType?: string
holeGroupRole?: string
surfaceFaces?: string[]
suggestedFasteners?: CADSuggestedFastener[]
}
@ -49,8 +50,28 @@ export interface PersistedSkuCadHoleGroup {
holeIds: string[]
position: Vector3Tuple
normal: Vector3Tuple
mountFace?: string
confidence?: number
reason?: string
surfaceFaces?: string[]
warnings?: string[]
}
export interface PersistedSkuCadMountPattern {
id: string
panelId: string
mountFace: string
patternType: string
holeIds: string[]
requiredHoleIds?: string[]
optionalHoleIds?: string[]
minRequiredHoles: number
preferredHoleCount: number
symmetry?: boolean
position: Vector3Tuple
normal: Vector3Tuple
confidence?: number
warnings?: string[]
}
export interface PersistedSkuCadHardwareInstance {
@ -84,10 +105,12 @@ export interface PersistedSkuCadData {
source: 'hardware.json'
pluginVersion: string
units: string
coordinateSystem?: string
panelCount: number
panels: PersistedSkuCadPanel[]
holes: PersistedSkuCadHole[]
holeGroups: PersistedSkuCadHoleGroup[]
mountPatterns: PersistedSkuCadMountPattern[]
hardwareTypes: PersistedSkuCadHardwareType[]
hardwareSummary: CADHardwareSummary[]
assembly?: PersistedSkuCadAssemblyData
@ -145,6 +168,18 @@ function toBoundingBox(value: unknown): PersistedSkuCadBoundingBox | undefined {
}
}
function normalizeStringArray(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const normalized = value.filter(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
)
return normalized.length > 0 ? normalized.map((item) => item.trim()) : undefined
}
function normalizePanelTypeLabel(panelType?: string): string {
const typeNameMap: Record<string, string> = {
side_panel: '侧板',
@ -278,6 +313,7 @@ function normalizeCadHoles(rawHoles: unknown): PersistedSkuCadHole[] {
typeof record.holeGroupRole === 'string' && record.holeGroupRole.trim().length > 0
? record.holeGroupRole.trim()
: undefined,
surfaceFaces: normalizeStringArray(record.surfaceFaces),
suggestedFasteners: normalizeSuggestedFasteners(record.suggestedFasteners),
})
})
@ -386,6 +422,10 @@ function normalizeCadHoleGroups(rawGroups: unknown): PersistedSkuCadHoleGroup[]
: [],
position: toVector3Tuple(record.position),
normal: toVector3Tuple(record.normal, [0, 0, 1]),
mountFace:
typeof record.mountFace === 'string' && record.mountFace.trim().length > 0
? record.mountFace.trim()
: undefined,
confidence:
typeof record.confidence === 'number' && Number.isFinite(record.confidence)
? record.confidence
@ -394,12 +434,77 @@ function normalizeCadHoleGroups(rawGroups: unknown): PersistedSkuCadHoleGroup[]
typeof record.reason === 'string' && record.reason.trim().length > 0
? record.reason.trim()
: undefined,
surfaceFaces: normalizeStringArray(record.surfaceFaces),
warnings: normalizeStringArray(record.warnings),
})
})
return groups
}
function normalizeCadMountPatterns(rawPatterns: unknown): PersistedSkuCadMountPattern[] {
if (!Array.isArray(rawPatterns)) {
return []
}
const patterns: PersistedSkuCadMountPattern[] = []
const seen = new Set<string>()
rawPatterns.forEach((entry, index) => {
if (!entry || typeof entry !== 'object') {
return
}
const record = entry as Record<string, unknown>
const id =
typeof record.id === 'string' && record.id.trim().length > 0
? record.id.trim()
: `cad-mount-pattern-${index + 1}`
if (seen.has(id)) {
return
}
seen.add(id)
const panelId = typeof record.panelId === 'string' ? record.panelId.trim() : ''
const mountFace = typeof record.mountFace === 'string' ? record.mountFace.trim() : ''
if (!panelId || !mountFace) {
return
}
const holeIds = Array.isArray(record.holeIds)
? record.holeIds.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
: []
if (holeIds.length === 0) {
return
}
patterns.push({
id,
panelId,
mountFace,
patternType:
typeof record.patternType === 'string' && record.patternType.trim().length > 0
? record.patternType.trim()
: 'generic',
holeIds,
requiredHoleIds: normalizeStringArray(record.requiredHoleIds),
optionalHoleIds: normalizeStringArray(record.optionalHoleIds),
minRequiredHoles: Math.max(1, toPositiveNumber(record.minRequiredHoles, holeIds.length)),
preferredHoleCount: Math.max(1, toPositiveNumber(record.preferredHoleCount, holeIds.length)),
symmetry: record.symmetry === true,
position: toVector3Tuple(record.position),
normal: toVector3Tuple(record.normal, [0, 0, 1]),
confidence:
typeof record.confidence === 'number' && Number.isFinite(record.confidence)
? record.confidence
: undefined,
warnings: normalizeStringArray(record.warnings),
})
})
return patterns
}
function normalizeCadHardwareTypes(rawHardwareTypes: unknown): PersistedSkuCadHardwareType[] {
if (!Array.isArray(rawHardwareTypes)) {
return []
@ -571,9 +676,11 @@ function normalizeHardwareSummary(rawSummary: unknown): CADHardwareSummary[] {
export function buildPersistedSkuCadData(params: {
pluginVersion?: string
units?: string
coordinateSystem?: unknown
panels?: unknown
holes?: unknown
holeGroups?: unknown
mountPatterns?: unknown
hardwareTypes?: unknown
hardwareSummary?: unknown
assembly?: unknown
@ -581,6 +688,7 @@ export function buildPersistedSkuCadData(params: {
const panels = normalizeCadPanels(params.panels)
const holes = mergeCadHoles(normalizeCadHoles(params.holes), params.panels)
const holeGroups = normalizeCadHoleGroups(params.holeGroups)
const mountPatterns = normalizeCadMountPatterns(params.mountPatterns)
const hardwareTypes = normalizeCadHardwareTypes(params.hardwareTypes)
const hardwareSummary = normalizeHardwareSummary(params.hardwareSummary)
const assembly = normalizePersistedSkuCadAssemblyData(params.assembly)
@ -589,6 +697,7 @@ export function buildPersistedSkuCadData(params: {
panels.length === 0 &&
holes.length === 0 &&
holeGroups.length === 0 &&
mountPatterns.length === 0 &&
hardwareTypes.length === 0 &&
hardwareSummary.length === 0 &&
!assembly
@ -600,10 +709,15 @@ export function buildPersistedSkuCadData(params: {
source: 'hardware.json',
pluginVersion: typeof params.pluginVersion === 'string' ? params.pluginVersion : 'unknown',
units: typeof params.units === 'string' && params.units.trim().length > 0 ? params.units.trim() : 'mm',
coordinateSystem:
typeof params.coordinateSystem === 'string' && params.coordinateSystem.trim().length > 0
? params.coordinateSystem.trim()
: undefined,
panelCount: panels.length,
panels,
holes,
holeGroups,
mountPatterns,
hardwareTypes,
hardwareSummary,
...(assembly ? { assembly } : {}),
@ -624,9 +738,11 @@ export function readSkuCadDataFromAttributes(attributes: unknown): PersistedSkuC
return buildPersistedSkuCadData({
pluginVersion: typeof record.pluginVersion === 'string' ? record.pluginVersion : undefined,
units: typeof record.units === 'string' ? record.units : undefined,
coordinateSystem: record.coordinateSystem,
panels: record.panels,
holes: record.holes,
holeGroups: record.holeGroups,
mountPatterns: record.mountPatterns,
hardwareTypes: record.hardwareTypes,
hardwareSummary: record.hardwareSummary,
assembly: record.assembly,
@ -744,5 +860,5 @@ export function enrichPanelsWithSkuCadSpatial<T extends { gltfNodeName: string }
cadBoundingBox: cadPanel?.boundingBox ?? null,
sourceLayer: cadPanel?.sourceLayer ?? null,
}
})
}) as Array<T & SkuCadSpatialFields>
}

View File

@ -1,4 +1,5 @@
import type { AssetStatus, AssetType } from '@prisma/client'
import type { AssetVariantUvConfig } from '@/lib/asset-variant-render-config'
import type { OpenProductMountPoint } from '@/lib/open-product/types'
export interface AssetWorkflowAuditDTO {
@ -59,6 +60,7 @@ export interface AssetVariantDTO {
compatibilityRules: Record<string, unknown> | null
namingFields: Record<string, unknown> | null
businessData: Record<string, unknown> | null
uvConfig?: AssetVariantUvConfig | null
weightKg: number | null
costPrice: number | null
lossFactor: number | null
@ -181,6 +183,7 @@ export interface CreateAssetVariantInput {
compatibilityRules?: Record<string, unknown> | null
namingFields?: Record<string, unknown> | null
businessData?: Record<string, unknown> | null
uvConfig?: AssetVariantUvConfig | null
weightKg?: number | null
costPrice?: number | null
lossFactor?: number | null
@ -205,6 +208,7 @@ export interface UpdateAssetVariantInput {
compatibilityRules?: Record<string, unknown> | null
namingFields?: Record<string, unknown> | null
businessData?: Record<string, unknown> | null
uvConfig?: AssetVariantUvConfig | null
weightKg?: number | null
costPrice?: number | null
lossFactor?: number | null

View File

@ -1,352 +1,317 @@
param(
[string]$ProjectRoot = "D:\AI Code\3danzhuang\furniture-3d-system",
[string]$OutputRoot = "D:\AI Code\3danzhuang",
param(
[string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot),
[string]$ProjectSubdir = "furniture-3d-system",
[string]$OutputRoot = "",
[string]$Ref = "origin/main",
[string]$RemoteBase = "/opt/furniture-3d-system-prod",
[string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai",
[int]$AppPort = 3001,
[string]$PublicAppUrl = "",
[string]$Pm2Name = "furniture-3d-system-prod",
[string]$DbName = "furniture3d_prod",
[string]$DbUser = "furniture3d_prod",
[string]$DbPass = "",
[string]$HrSyncSecret = "",
[string]$S3Endpoint = "http://192.168.58.20:9000",
[string]$S3Bucket = "gongxiangpan",
[string]$S3Region = "us-east-1",
[string]$S3PublicDomain = "https://s3.jiansun.vip/gongxiangpan",
[string]$S3FilePath = "3danzhuang/furniture-3d-system/"
[int]$KeepReleases = 5
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $ProjectRoot)) {
throw "项目目录不存在: $ProjectRoot"
if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
$OutputRoot = $RepoRoot
}
function Get-EnvValue {
if (-not (Test-Path -LiteralPath $RepoRoot)) {
throw "仓库目录不存在: $RepoRoot"
}
if (-not (Test-Path -LiteralPath $OutputRoot)) {
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null
}
$projectRoot = Join-Path $RepoRoot $ProjectSubdir
if (-not (Test-Path -LiteralPath $projectRoot)) {
throw "项目目录不存在: $projectRoot"
}
function Invoke-NativeCommand {
param(
[string]$Path,
[string]$Key
[string]$FilePath,
[string[]]$Arguments,
[string]$WorkingDirectory = $RepoRoot
)
if (-not (Test-Path -LiteralPath $Path)) {
return $null
}
$previousErrorActionPreference = $ErrorActionPreference
$line = Get-Content -LiteralPath $Path | Where-Object { $_ -match "^$([regex]::Escape($Key))=" } | Select-Object -First 1
if (-not $line) {
return $null
}
$value = $line.Substring($line.IndexOf('=') + 1).Trim()
if ($value.Length -ge 2) {
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
$value = $value.Substring(1, $value.Length - 2)
try {
$ErrorActionPreference = 'Continue'
Push-Location -LiteralPath $WorkingDirectory
try {
$output = & $FilePath @Arguments 2>&1
$exitCode = $LASTEXITCODE
}
finally {
Pop-Location
}
$lines = @(
$output |
ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) {
$_.ToString()
}
else {
[string]$_
}
} |
Where-Object { $_.Length -gt 0 }
)
if ($exitCode -ne 0) {
throw (($lines -join [Environment]::NewLine).Trim())
}
return $lines
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
}
return $value
}
function New-RandomSecret([int]$Length = 48) {
$bytes = New-Object byte[] $Length
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
return [Convert]::ToBase64String($bytes).Replace('+', 'A').Replace('/', 'B').TrimEnd('=').Substring(0, $Length)
function Invoke-Git {
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$Arguments
)
return Invoke-NativeCommand -FilePath 'git' -Arguments (@('-C', $RepoRoot) + $Arguments) -WorkingDirectory $RepoRoot
}
$localEnvPath = Join-Path $ProjectRoot '.env'
$jwtSecret = Get-EnvValue -Path $localEnvPath -Key 'JWT_SECRET'
if ([string]::IsNullOrWhiteSpace($jwtSecret) -or $jwtSecret.Length -lt 32) {
$jwtSecret = New-RandomSecret 48
if ($Ref -match '^(origin/|refs/remotes/origin/)') {
Invoke-Git fetch origin | Out-Null
}
$nextAuthSecret = Get-EnvValue -Path $localEnvPath -Key 'NEXTAUTH_SECRET'
if ([string]::IsNullOrWhiteSpace($nextAuthSecret) -or $nextAuthSecret.Length -lt 32) {
$nextAuthSecret = New-RandomSecret 48
$commit = (Invoke-Git rev-parse $Ref | Select-Object -First 1).Trim()
if ([string]::IsNullOrWhiteSpace($commit)) {
throw "无法解析部署引用: $Ref"
}
if ([string]::IsNullOrWhiteSpace($DbPass)) {
$DbPass = New-RandomSecret 32
}
if ([string]::IsNullOrWhiteSpace($HrSyncSecret)) {
$HrSyncSecret = Get-EnvValue -Path $localEnvPath -Key 'HR_SYNC_SECRET'
}
if ([string]::IsNullOrWhiteSpace($HrSyncSecret)) {
$HrSyncSecret = Get-EnvValue -Path $localEnvPath -Key 'INTERNAL_SYNC_SECRET'
}
if ([string]::IsNullOrWhiteSpace($HrSyncSecret) -or $HrSyncSecret.Length -lt 32) {
$HrSyncSecret = New-RandomSecret 48
}
if ([string]::IsNullOrWhiteSpace($PublicAppUrl)) {
$publicAppUrl = "http://$ServerHost`:$AppPort"
} else {
$publicAppUrl = $PublicAppUrl.TrimEnd('/')
}
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$archiveName = "deploy-furniture-prod-$timestamp.tar.gz"
$scriptName = "deploy-furniture-prod-$timestamp.sh"
$shortCommit = $commit.Substring(0, 7)
$releaseId = "{0}-{1}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), $shortCommit
$archiveName = "deploy-furniture-prod-$releaseId.tar.gz"
$scriptName = "deploy-furniture-prod-$releaseId.sh"
$archivePath = Join-Path $OutputRoot $archiveName
$scriptPath = Join-Path $OutputRoot $scriptName
$remoteIncoming = "$RemoteBase/incoming"
$remoteArchivePath = "$remoteIncoming/$archiveName"
$remoteScriptPath = "$remoteIncoming/$scriptName"
$includePaths = @(
'furniture-3d-system/package.json'
'furniture-3d-system/package-lock.json'
'furniture-3d-system/.npmrc'
'furniture-3d-system/next.config.js'
'furniture-3d-system/postcss.config.mjs'
'furniture-3d-system/tsconfig.json'
'furniture-3d-system/next-env.d.ts'
'furniture-3d-system/prisma.config.ts'
'furniture-3d-system/src'
'furniture-3d-system/public'
'furniture-3d-system/prisma'
'furniture-3d-system/scripts'
'furniture-3d-system/lib'
)
Push-Location (Split-Path $ProjectRoot -Parent)
try {
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
tar -czf $archivePath @includePaths
} finally {
Pop-Location
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
if (Test-Path -LiteralPath $scriptPath) {
Remove-Item -LiteralPath $scriptPath -Force
}
Invoke-Git archive --format=tar.gz "--output=$archivePath" "--prefix=$ProjectSubdir/" "$commit`:$ProjectSubdir" | Out-Null
$shellScript = @'
set -e
BASE=__REMOTE_BASE__
APP=$BASE/furniture-3d-system
ARCHIVE=$BASE/__ARCHIVE_NAME__
DB_NAME=__DB_NAME__
DB_USER=__DB_USER__
DB_PASS='__DB_PASS__'
APP_PORT=__APP_PORT__
PM2_NAME=__PM2_NAME__
PUBLIC_APP_URL='__PUBLIC_APP_URL__'
S3_ENDPOINT='__S3_ENDPOINT__'
S3_BUCKET='__S3_BUCKET__'
S3_REGION='__S3_REGION__'
S3_PUBLIC_DOMAIN='__S3_PUBLIC_DOMAIN__'
S3_FILE_PATH='__S3_FILE_PATH__'
NEXTAUTH_SECRET='__NEXTAUTH_SECRET__'
JWT_SECRET='__JWT_SECRET__'
HR_SYNC_SECRET='__HR_SYNC_SECRET__'
#!/usr/bin/env bash
set -euo pipefail
EXISTING_ENV_PATH="$BASE/.env"
read_env_value() {
local key="$1"
if [ ! -f "$EXISTING_ENV_PATH" ]; then
return 0
fi
local line
line=$(grep -E "^${key}=" "$EXISTING_ENV_PATH" | head -n 1 || true)
if [ -z "$line" ]; then
return 0
fi
local value="${line#*=}"
value="${value%\"}"
value="${value#\"}"
value="${value%\'}"
value="${value#\'}"
printf '%s' "$value"
BASE="__REMOTE_BASE__"
INCOMING="$BASE/incoming"
RELEASES="$BASE/releases"
SHARED="$BASE/shared"
CURRENT="$BASE/current"
LEGACY_APP="$BASE/__PROJECT_SUBDIR__"
RELEASE_ID="__RELEASE_ID__"
ARCHIVE="$INCOMING/__ARCHIVE_NAME__"
APP_PORT="__APP_PORT__"
PM2_NAME="__PM2_NAME__"
KEEP_RELEASES="__KEEP_RELEASES__"
COMMIT_SHA="__COMMIT_SHA__"
SOURCE_REF="__SOURCE_REF__"
assert_path_in_base() {
local target="$1"
case "$target" in
"$BASE"/*) ;;
*)
echo "Refuse to touch path outside deployment base: $target" >&2
exit 1
;;
esac
}
S3_ACCESS_KEY_VALUE="${S3_ACCESS_KEY:-${S3_ACCESS_KEY_ID:-$(read_env_value S3_ACCESS_KEY)}}"
S3_SECRET_KEY_VALUE="${S3_SECRET_KEY:-${S3_SECRET_ACCESS_KEY:-$(read_env_value S3_SECRET_KEY)}}"
NEXTAUTH_SECRET_VALUE="${NEXTAUTH_SECRET:-$(read_env_value NEXTAUTH_SECRET)}"
JWT_SECRET_VALUE="${JWT_SECRET:-$(read_env_value JWT_SECRET)}"
DINGTALK_CALLBACK_ORIGIN_VALUE="${DINGTALK_CALLBACK_ORIGIN:-$(read_env_value DINGTALK_CALLBACK_ORIGIN)}"
DINGTALK_CLIENT_ID_VALUE="${DINGTALK_CLIENT_ID:-$(read_env_value DINGTALK_CLIENT_ID)}"
DINGTALK_CLIENT_SECRET_VALUE="${DINGTALK_CLIENT_SECRET:-$(read_env_value DINGTALK_CLIENT_SECRET)}"
HR_SYNC_SECRET_VALUE="${HR_SYNC_SECRET:-${INTERNAL_SYNC_SECRET:-$(read_env_value HR_SYNC_SECRET)}}"
copy_legacy_env_if_needed() {
if [ -f "$SHARED/.env" ]; then
return 0
fi
if [ -z "$NEXTAUTH_SECRET_VALUE" ]; then
NEXTAUTH_SECRET_VALUE="$NEXTAUTH_SECRET"
fi
if [ -z "$JWT_SECRET_VALUE" ]; then
JWT_SECRET_VALUE="$JWT_SECRET"
fi
if [ -z "$DINGTALK_CALLBACK_ORIGIN_VALUE" ]; then
DINGTALK_CALLBACK_ORIGIN_VALUE="$PUBLIC_APP_URL"
fi
if [ -z "$HR_SYNC_SECRET_VALUE" ]; then
HR_SYNC_SECRET_VALUE="$HR_SYNC_SECRET"
fi
mkdir -p "$SHARED"
if [ -z "$S3_ACCESS_KEY_VALUE" ] || [ -z "$S3_SECRET_KEY_VALUE" ]; then
echo 'Missing shared S3 credentials. Export S3_ACCESS_KEY / S3_SECRET_KEY (or AWS-style names) before running this script.'
if [ -f "$BASE/.env" ]; then
cp "$BASE/.env" "$SHARED/.env"
return 0
fi
if [ -f "$LEGACY_APP/.env" ]; then
cp "$LEGACY_APP/.env" "$SHARED/.env"
return 0
fi
echo "Missing shared env file. Expected one of:" >&2
echo " $SHARED/.env" >&2
echo " $BASE/.env" >&2
echo " $LEGACY_APP/.env" >&2
exit 1
}
start_pm2_release() {
local release_root="$1"
local app_root="$release_root/__PROJECT_SUBDIR__"
local start_command
start_command="cd '$app_root' && set -a && source '$SHARED/.env' && set +a && exec ./node_modules/.bin/next start -H 0.0.0.0 -p $APP_PORT"
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
pm2 start /bin/bash --name "$PM2_NAME" -- -lc "$start_command"
pm2 save >/dev/null
}
cleanup_old_releases() {
if [ "$KEEP_RELEASES" -le 0 ]; then
return 0
fi
mapfile -t release_dirs < <(find "$RELEASES" -mindepth 1 -maxdepth 1 -type d | sort)
local total="${#release_dirs[@]}"
if [ "$total" -le "$KEEP_RELEASES" ]; then
return 0
fi
local current_real=""
current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)"
local remove_count=$((total - KEEP_RELEASES))
for ((i=0; i<remove_count; i++)); do
local candidate="${release_dirs[$i]}"
if [ -n "$current_real" ] && [ "$candidate" = "$current_real" ]; then
continue
fi
assert_path_in_base "$candidate"
rm -rf -- "$candidate"
done
}
mkdir -p "$INCOMING" "$RELEASES" "$SHARED"
copy_legacy_env_if_needed
RELEASE_DIR="$RELEASES/$RELEASE_ID"
APP_DIR="$RELEASE_DIR/__PROJECT_SUBDIR__"
PREVIOUS_RELEASE=""
PREVIOUS_RELEASE="$(readlink -f "$CURRENT" 2>/dev/null || true)"
HEALTHCHECK_FILE="/tmp/furniture-3d-system-prod-healthcheck-$RELEASE_ID.txt"
DEPLOY_SUCCEEDED=0
cleanup_failed_release() {
local status="$1"
rm -f -- "${HEALTHCHECK_FILE:-}" >/dev/null 2>&1 || true
if [ "$DEPLOY_SUCCEEDED" -eq 1 ]; then
exit "$status"
fi
if [ -n "${RELEASE_DIR:-}" ] && [ -d "$RELEASE_DIR" ]; then
local current_real=""
current_real="$(readlink -f "$CURRENT" 2>/dev/null || true)"
if [ -n "$current_real" ] && [ "$current_real" = "$RELEASE_DIR" ]; then
if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then
ln -sfn "$PREVIOUS_RELEASE" "$CURRENT"
if ! start_pm2_release "$CURRENT"; then
echo "Rollback start failed for previous release: $PREVIOUS_RELEASE" >&2
status=1
fi
fi
fi
assert_path_in_base "$RELEASE_DIR"
rm -rf -- "$RELEASE_DIR"
fi
exit "$status"
}
trap 'cleanup_failed_release "$?"' EXIT
assert_path_in_base "$RELEASE_DIR"
rm -rf -- "$RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
tar -xzf "$ARCHIVE" -C "$RELEASE_DIR"
if [ ! -d "$APP_DIR" ]; then
echo "Archive extracted but app directory missing: $APP_DIR" >&2
exit 1
fi
mkdir -p $BASE
mkdir -p $APP
OLD_LOCK_HASH=""
if [ -f "$APP/package-lock.json" ]; then
OLD_LOCK_HASH=$(sha256sum "$APP/package-lock.json" | awk '{print $1}')
fi
printf '%s\n' "$COMMIT_SHA" > "$RELEASE_DIR/REVISION"
printf '%s\n' "$SOURCE_REF" > "$RELEASE_DIR/SOURCE_REF"
rm -rf \
$APP/src \
$APP/public \
$APP/prisma \
$APP/scripts \
$APP/lib \
$APP/.next
ln -sfn "$SHARED/.env" "$APP_DIR/.env"
rm -f \
$APP/package.json \
$APP/package-lock.json \
$APP/.npmrc \
$APP/next.config.js \
$APP/postcss.config.mjs \
$APP/tsconfig.json \
$APP/next-env.d.ts \
$APP/prisma.config.ts
cd $BASE
tar -xzf $ARCHIVE
NEW_LOCK_HASH=""
if [ -f "$APP/package-lock.json" ]; then
NEW_LOCK_HASH=$(sha256sum "$APP/package-lock.json" | awk '{print $1}')
fi
sudo -u postgres psql -d postgres <<SQL
DO \$\$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DB_USER') THEN
CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS';
END IF;
END
\$\$;
ALTER ROLE $DB_USER WITH PASSWORD '$DB_PASS';
SQL
if ! sudo -u postgres psql -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
sudo -u postgres createdb -O $DB_USER $DB_NAME
fi
sudo -u postgres psql -d postgres <<SQL
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
ALTER DATABASE $DB_NAME OWNER TO $DB_USER;
SQL
sudo -u postgres psql -d $DB_NAME <<SQL
GRANT ALL PRIVILEGES ON SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO $DB_USER;
SQL
cat > $BASE/.env <<EOF
NODE_ENV=production
PORT=$APP_PORT
HOSTNAME=0.0.0.0
DATABASE_URL=postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME
DIRECT_URL=postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME
NEXTAUTH_URL=$PUBLIC_APP_URL
NEXT_PUBLIC_APP_URL=$PUBLIC_APP_URL
NEXTAUTH_SECRET=$NEXTAUTH_SECRET_VALUE
JWT_SECRET=$JWT_SECRET_VALUE
S3_ENDPOINT=$S3_ENDPOINT
S3_BUCKET=$S3_BUCKET
S3_REGION=$S3_REGION
S3_ENABLE_PATH_STYLE=1
S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN
S3_FILE_PATH=$S3_FILE_PATH
S3_ACCESS_KEY=$S3_ACCESS_KEY_VALUE
S3_SECRET_KEY=$S3_SECRET_KEY_VALUE
PUPPETEER_SKIP_DOWNLOAD=true
DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN_VALUE
HR_SYNC_SECRET=$HR_SYNC_SECRET_VALUE
EOF
if [ -n "$DINGTALK_CLIENT_ID_VALUE" ]; then
echo "DINGTALK_CLIENT_ID=$DINGTALK_CLIENT_ID_VALUE" >> $BASE/.env
fi
if [ -n "$DINGTALK_CLIENT_SECRET_VALUE" ]; then
echo "DINGTALK_CLIENT_SECRET=$DINGTALK_CLIENT_SECRET_VALUE" >> $BASE/.env
fi
ln -sfn $BASE/.env $APP/.env
cd $APP
cd "$APP_DIR"
export PUPPETEER_SKIP_DOWNLOAD=true
if [ ! -d "$APP/node_modules" ] || [ "$OLD_LOCK_HASH" != "$NEW_LOCK_HASH" ]; then
echo '=== Install or update dependencies ==='
npm install --no-audit --no-fund
else
echo '=== Reuse existing dependencies (package-lock unchanged) ==='
fi
if find /root/.cache/puppeteer -type f -name chrome-headless-shell 2>/dev/null | grep -q .; then
echo '=== Reuse existing chrome-headless-shell ==='
else
echo '=== Install chrome-headless-shell ==='
PUPPETEER_CACHE_DIR=/root/.cache/puppeteer npx puppeteer browsers install chrome-headless-shell@stable
fi
npm ci --no-audit --no-fund
npx prisma generate
PRISMA_SCHEMA_SYNC_SQL=/tmp/furniture-3d-system-prod-prisma-schema-sync.sql
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script -o "$PRISMA_SCHEMA_SYNC_SQL"
if [ -s "$PRISMA_SCHEMA_SYNC_SQL" ]; then
echo '=== Sync Prisma schema diff ==='
sudo -u postgres psql -v ON_ERROR_STOP=1 -d $DB_NAME -f "$PRISMA_SCHEMA_SYNC_SQL"
else
echo '=== Prisma schema already up to date ==='
fi
npx prisma migrate deploy
npm run build
mkdir -p "$APP/.next/standalone/.next"
rm -rf "$APP/.next/standalone/.next/static" "$APP/.next/standalone/public"
cp -R "$APP/.next/static" "$APP/.next/standalone/.next/static"
cp -R "$APP/public" "$APP/.next/standalone/public"
ln -sfn "$RELEASE_DIR" "$CURRENT"
start_pm2_release "$CURRENT"
pm2 delete $PM2_NAME >/dev/null 2>&1 || true
pm2 start /bin/bash --name $PM2_NAME -- -lc "cd '$APP' && set -a && source '$APP/.env' && set +a && mkdir -p '$APP/.next/standalone/.next' && rm -rf '$APP/.next/standalone/.next/static' '$APP/.next/standalone/public' && cp -R '$APP/.next/static' '$APP/.next/standalone/.next/static' && cp -R '$APP/public' '$APP/.next/standalone/public' && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node '$APP/.next/standalone/server.js'"
pm2 save >/dev/null
sleep 5
curl -I -s http://127.0.0.1:$APP_PORT | sed -n '1,12p'
echo '=== DONE ==='
pm2 describe $PM2_NAME | sed -n '1,35p'
if ! curl -fsS -I "http://127.0.0.1:$APP_PORT" >"$HEALTHCHECK_FILE"; then
echo "New release health check failed. Rolling back..." >&2
if [ -n "$PREVIOUS_RELEASE" ] && [ -d "$PREVIOUS_RELEASE" ]; then
ln -sfn "$PREVIOUS_RELEASE" "$CURRENT"
start_pm2_release "$CURRENT"
fi
exit 1
fi
sed -n '1,12p' "$HEALTHCHECK_FILE"
cleanup_old_releases
rm -f -- "$ARCHIVE"
rm -f -- "$HEALTHCHECK_FILE"
DEPLOY_SUCCEEDED=1
trap - EXIT
echo "=== SAFE RELEASE DEPLOY DONE ==="
pm2 describe "$PM2_NAME" | sed -n '1,35p'
'@
$shellScript = $shellScript.Replace('__REMOTE_BASE__', $RemoteBase)
$shellScript = $shellScript.Replace('__PROJECT_SUBDIR__', $ProjectSubdir)
$shellScript = $shellScript.Replace('__RELEASE_ID__', $releaseId)
$shellScript = $shellScript.Replace('__ARCHIVE_NAME__', $archiveName)
$shellScript = $shellScript.Replace('__DB_NAME__', $DbName)
$shellScript = $shellScript.Replace('__DB_USER__', $DbUser)
$shellScript = $shellScript.Replace('__DB_PASS__', $DbPass)
$shellScript = $shellScript.Replace('__APP_PORT__', [string]$AppPort)
$shellScript = $shellScript.Replace('__PM2_NAME__', $Pm2Name)
$shellScript = $shellScript.Replace('__PUBLIC_APP_URL__', $publicAppUrl)
$shellScript = $shellScript.Replace('__S3_ENDPOINT__', $S3Endpoint)
$shellScript = $shellScript.Replace('__S3_BUCKET__', $S3Bucket)
$shellScript = $shellScript.Replace('__S3_REGION__', $S3Region)
$shellScript = $shellScript.Replace('__S3_PUBLIC_DOMAIN__', $S3PublicDomain)
$shellScript = $shellScript.Replace('__S3_FILE_PATH__', $S3FilePath)
$shellScript = $shellScript.Replace('__NEXTAUTH_SECRET__', $nextAuthSecret)
$shellScript = $shellScript.Replace('__JWT_SECRET__', $jwtSecret)
$shellScript = $shellScript.Replace('__HR_SYNC_SECRET__', $HrSyncSecret)
$shellScript = $shellScript.Replace('__KEEP_RELEASES__', [string]$KeepReleases)
$shellScript = $shellScript.Replace('__COMMIT_SHA__', $commit)
$shellScript = $shellScript.Replace('__SOURCE_REF__', $Ref)
[System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false))
$archive = Get-Item -LiteralPath $archivePath
[PSCustomObject]@{
Mode = 'safe-git-release'
Ref = $Ref
Commit = $commit
Archive = $archive.FullName
ArchiveMB = [math]::Round($archive.Length / 1MB, 2)
Script = $scriptPath
RemoteArchivePath = $remoteArchivePath
RemoteScriptPath = $remoteScriptPath
RemoteBase = $RemoteBase
ServerHost = $ServerHost
AppPort = $AppPort
PublicAppUrl = $publicAppUrl
S3Bucket = $S3Bucket
S3FilePath = $S3FilePath
Pm2Name = $Pm2Name
KeepReleases = $KeepReleases
}

View File

@ -0,0 +1,188 @@
param(
[string]$RepoRoot = $(Split-Path -Parent $PSScriptRoot),
[string]$Ref = "origin/main",
[string]$RemoteBase = "/opt/furniture-3d-system-prod",
[string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai",
[int]$SshPort = 60022,
[string]$ServerUser = "root",
[string]$ServerPassword = "",
[string]$HostKeyFingerprint = "SHA256:JPHcomdjuUZOTM7sNdBYg0/xRkQSXzbXe7FEfNtIFAU",
[int]$AppPort = 3001,
[string]$Pm2Name = "furniture-3d-system-prod",
[int]$KeepReleases = 5,
[switch]$KeepArtifacts
)
$ErrorActionPreference = 'Stop'
$createScript = Join-Path $RepoRoot 'scripts\create-yydd-prod-deploy.ps1'
$plinkPath = 'C:\Program Files\PuTTY\plink.exe'
$pscpPath = 'C:\Program Files\PuTTY\pscp.exe'
if (-not (Test-Path -LiteralPath $createScript)) {
throw "缺少打包脚本: $createScript"
}
if (-not (Test-Path -LiteralPath $plinkPath)) {
throw "缺少 plink.exe: $plinkPath"
}
if (-not (Test-Path -LiteralPath $pscpPath)) {
throw "缺少 pscp.exe: $pscpPath"
}
function Invoke-NativeCommand {
param(
[string]$FilePath,
[string[]]$Arguments,
[string]$WorkingDirectory = $RepoRoot
)
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
Push-Location -LiteralPath $WorkingDirectory
try {
$output = & $FilePath @Arguments 2>&1
$exitCode = $LASTEXITCODE
}
finally {
Pop-Location
}
$lines = @(
$output |
ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) {
$_.ToString()
}
else {
[string]$_
}
} |
Where-Object { $_.Length -gt 0 }
)
if ($exitCode -ne 0) {
throw (($lines -join [Environment]::NewLine).Trim())
}
return $lines
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
}
}
function Invoke-Plink {
param(
[string]$Command
)
$arguments = @(
'-ssh'
'-batch'
'-hostkey'
$HostKeyFingerprint
'-P'
[string]$SshPort
)
if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) {
$arguments += @('-pw', $ServerPassword)
}
$arguments += @(
('{0}@{1}' -f $ServerUser, $ServerHost)
$Command
)
return Invoke-NativeCommand -FilePath $plinkPath -Arguments $arguments -WorkingDirectory $RepoRoot
}
function Copy-WithPscp {
param(
[string]$LocalPath,
[string]$RemotePath
)
if (-not (Test-Path -LiteralPath $LocalPath)) {
throw "上传文件不存在: $LocalPath"
}
$arguments = @(
'-batch'
'-hostkey'
$HostKeyFingerprint
'-P'
[string]$SshPort
)
if (-not [string]::IsNullOrWhiteSpace($ServerPassword)) {
$arguments += @('-pw', $ServerPassword)
}
$arguments += @(
$LocalPath
('{0}@{1}:{2}' -f $ServerUser, $ServerHost, $RemotePath)
)
return Invoke-NativeCommand -FilePath $pscpPath -Arguments $arguments -WorkingDirectory $RepoRoot
}
$package = & $createScript `
-RepoRoot $RepoRoot `
-Ref $Ref `
-RemoteBase $RemoteBase `
-ServerHost $ServerHost `
-AppPort $AppPort `
-Pm2Name $Pm2Name `
-KeepReleases $KeepReleases
if (-not $package) {
throw '打包脚本未返回结果'
}
$archivePath = [string]$package.Archive
$scriptPath = [string]$package.Script
$remoteArchivePath = [string]$package.RemoteArchivePath
$remoteScriptPath = [string]$package.RemoteScriptPath
$remoteIncoming = ('{0}/incoming' -f $RemoteBase)
$mkdirIncomingCommand = "mkdir -p '{0}'" -f $remoteIncoming
$runDeployCommand = "chmod +x '{0}' && bash '{0}'" -f $remoteScriptPath
try {
Invoke-Plink $mkdirIncomingCommand | Out-Null
Copy-WithPscp -LocalPath $archivePath -RemotePath $remoteArchivePath | Out-Null
Copy-WithPscp -LocalPath $scriptPath -RemotePath $remoteScriptPath | Out-Null
$deployOutput = Invoke-Plink $runDeployCommand
}
finally {
if (-not $KeepArtifacts) {
try {
Invoke-Plink ("rm -f '{0}' '{1}'" -f $remoteArchivePath, $remoteScriptPath) | Out-Null
}
catch {
Write-Warning ("远端部署临时文件清理失败: {0}" -f $_.Exception.Message)
}
if (Test-Path -LiteralPath $archivePath) {
Remove-Item -LiteralPath $archivePath -Force
}
if (Test-Path -LiteralPath $scriptPath) {
Remove-Item -LiteralPath $scriptPath -Force
}
}
}
[PSCustomObject]@{
Mode = 'safe-git-release-deploy'
Ref = [string]$package.Ref
Commit = [string]$package.Commit
RemoteBase = $RemoteBase
RemoteArchivePath = $remoteArchivePath
RemoteScriptPath = $remoteScriptPath
ServerHost = $ServerHost
SshPort = $SshPort
Pm2Name = $Pm2Name
Output = ($deployOutput -join [Environment]::NewLine)
}