911 lines
30 KiB
TypeScript
911 lines
30 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffect, type ReactNode } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
ContextMenu,
|
|
calculateMenuPosition,
|
|
type ContextMenuGroup,
|
|
type ContextMenuItem,
|
|
} from '@/components/ui/ContextMenu'
|
|
import {
|
|
AssemblyMaterialDock,
|
|
type AssemblyMaterialDockAnchorRect,
|
|
} 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,
|
|
deriveAssemblyAssistFeedback,
|
|
moveAssemblyNodes,
|
|
removeAssemblyNodes,
|
|
instantiateAssemblyNode,
|
|
attemptSnapNode,
|
|
applyAutomaticAssemblySnapPreview,
|
|
updateAssemblyNodeTransform,
|
|
updateInventoryVariantSelection,
|
|
type AssemblyWorkspaceInventoryItem,
|
|
type AssemblyWorkspaceNode,
|
|
type AssemblyWorkspaceSnapshot,
|
|
} from '@/lib/open-product/assembly-workspace'
|
|
import type { ModelViewerWorldNode } from '@/components/3d/ModelViewer'
|
|
import type { ViewportPresetKey } from './ViewportStageShell'
|
|
|
|
type AssemblyWorkbenchTransformMode = 'translate' | 'rotate'
|
|
|
|
interface AssemblyWorkbenchV2Props {
|
|
value: AssemblyWorkspaceSnapshot
|
|
workspaceKey?: string
|
|
candidateName: string
|
|
statusTag: string
|
|
warnings: string[]
|
|
availableVariants: AssistedAssemblyVariant[]
|
|
onChange: (next: AssemblyWorkspaceSnapshot) => void
|
|
className?: string
|
|
title?: string
|
|
rightDrawerContent?: ReactNode
|
|
}
|
|
|
|
function areSnapshotsEqual(left: AssemblyWorkspaceSnapshot, right: AssemblyWorkspaceSnapshot) {
|
|
return JSON.stringify(left) === JSON.stringify(right)
|
|
}
|
|
|
|
function getSourceLabel(source: AssemblyWorkspaceNode['source']) {
|
|
if (source === 'desktop') return '桌面板'
|
|
if (source === 'leg') return '支撑件'
|
|
if (source === 'connector') return '连接件'
|
|
return '零件'
|
|
}
|
|
|
|
function buildSnapHolePanelId(nodeId: string, holeId: string) {
|
|
return `${nodeId}::hole::${holeId}`
|
|
}
|
|
|
|
function toOpenProductVariant(variant: AssistedAssemblyVariant) {
|
|
return {
|
|
id: variant.id,
|
|
assetFamilyId: variant.assetFamilyId,
|
|
code: variant.code,
|
|
name: variant.name,
|
|
assetType: variant.assetType,
|
|
status: variant.status,
|
|
modelUrl: variant.modelUrl,
|
|
thumbnailUrl: variant.thumbnailUrl,
|
|
dimensions: variant.dimensions,
|
|
mountPoints: variant.mountPoints,
|
|
substrateId: variant.substrateId,
|
|
finishId: variant.finishId,
|
|
uvConfig: variant.uvConfig,
|
|
weightKg: variant.weightKg,
|
|
costPrice: variant.costPrice,
|
|
assemblyBasis: variant.assemblyBasis,
|
|
compatibilityRules: variant.compatibilityRules,
|
|
materialConfig: getAssistedAssemblyVariantMaterialConfig(variant),
|
|
}
|
|
}
|
|
|
|
function getPreferredInventoryId(
|
|
items: AssemblyWorkspaceInventoryItem[],
|
|
availableVariantMap: Map<string, AssistedAssemblyVariant[]>,
|
|
) {
|
|
const previewableItem = items.find((item) => (availableVariantMap.get(item.familyId) ?? []).length > 0)
|
|
return previewableItem?.inventoryId ?? items[0]?.inventoryId ?? null
|
|
}
|
|
|
|
function buildAvailableVariantMap(availableVariants: AssistedAssemblyVariant[]) {
|
|
const map = new Map<string, AssistedAssemblyVariant[]>()
|
|
availableVariants.forEach((variant) => {
|
|
const current = map.get(variant.assetFamilyId) ?? []
|
|
current.push(variant)
|
|
map.set(variant.assetFamilyId, current)
|
|
})
|
|
return map
|
|
}
|
|
|
|
function cloneDockAnchorRect(rect: DOMRect): AssemblyMaterialDockAnchorRect {
|
|
return {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
right: rect.right,
|
|
bottom: rect.bottom,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
}
|
|
}
|
|
|
|
function areDockAnchorRectsEqual(
|
|
left: AssemblyMaterialDockAnchorRect | null,
|
|
right: AssemblyMaterialDockAnchorRect | null,
|
|
) {
|
|
if (left === right) return true
|
|
if (!left || !right) return false
|
|
|
|
return (
|
|
left.top === right.top &&
|
|
left.left === right.left &&
|
|
left.right === right.right &&
|
|
left.bottom === right.bottom &&
|
|
left.width === right.width &&
|
|
left.height === right.height
|
|
)
|
|
}
|
|
|
|
export function AssemblyWorkbenchV2({
|
|
value,
|
|
workspaceKey,
|
|
candidateName,
|
|
statusTag: _statusTag,
|
|
warnings,
|
|
availableVariants,
|
|
onChange,
|
|
className,
|
|
title,
|
|
rightDrawerContent,
|
|
}: AssemblyWorkbenchV2Props) {
|
|
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([])
|
|
const [presentWorkspace, setPresentWorkspace] = useState(value)
|
|
const [, setUndoStack] = useState<AssemblyWorkspaceSnapshot[]>([])
|
|
const [, setRedoStack] = useState<AssemblyWorkspaceSnapshot[]>([])
|
|
const [gizmoMode, setGizmoMode] = useState<AssemblyWorkbenchTransformMode>('translate')
|
|
const [showSceneGrid, setShowSceneGrid] = useState(true)
|
|
const [activePreset, setActivePreset] = useState<ViewportPresetKey | null>(null)
|
|
const [selectedInventoryId, setSelectedInventoryId] = useState<string | null>(() =>
|
|
getPreferredInventoryId(value.inventory, buildAvailableVariantMap(availableVariants)),
|
|
)
|
|
const [isMaterialDockOpen, setIsMaterialDockOpen] = useState(false)
|
|
const [isRightDrawerOpen, setIsRightDrawerOpen] = useState(false)
|
|
const [activeMaterialAnchorRect, setActiveMaterialAnchorRect] =
|
|
useState<AssemblyMaterialDockAnchorRect | null>(null)
|
|
const [materialViewportRect, setMaterialViewportRect] =
|
|
useState<AssemblyMaterialDockAnchorRect | null>(null)
|
|
const [contextMenuState, setContextMenuState] = useState<{
|
|
visible: boolean
|
|
x: number
|
|
y: number
|
|
nodeId: string | null
|
|
}>({
|
|
visible: false,
|
|
x: 0,
|
|
y: 0,
|
|
nodeId: null,
|
|
})
|
|
|
|
const materialSelectionRef = useRef<HTMLDivElement | null>(null)
|
|
const workspaceRef = useRef(value)
|
|
const pendingInteractiveSnapshotRef = useRef<AssemblyWorkspaceSnapshot | null>(null)
|
|
const pendingInteractiveNodeIdRef = useRef<string | null>(null)
|
|
const pendingInteractiveModeRef = useRef<AssemblyWorkbenchTransformMode | null>(null)
|
|
const suppressEmptySelectionUntilRef = useRef(0)
|
|
|
|
const availableVariantMap = useMemo(() => {
|
|
return buildAvailableVariantMap(availableVariants)
|
|
}, [availableVariants])
|
|
|
|
useEffect(() => {
|
|
setPresentWorkspace(value)
|
|
workspaceRef.current = value
|
|
}, [value])
|
|
|
|
useEffect(() => {
|
|
setPresentWorkspace(value)
|
|
workspaceRef.current = value
|
|
setUndoStack([])
|
|
setRedoStack([])
|
|
setSelectedNodeIds([])
|
|
setGizmoMode('translate')
|
|
setShowSceneGrid(true)
|
|
setActivePreset(null)
|
|
setSelectedInventoryId(getPreferredInventoryId(value.inventory, availableVariantMap))
|
|
setIsMaterialDockOpen(false)
|
|
setIsRightDrawerOpen(false)
|
|
setActiveMaterialAnchorRect(null)
|
|
setContextMenuState({ visible: false, x: 0, y: 0, nodeId: null })
|
|
pendingInteractiveSnapshotRef.current = null
|
|
pendingInteractiveNodeIdRef.current = null
|
|
pendingInteractiveModeRef.current = null
|
|
suppressEmptySelectionUntilRef.current = 0
|
|
}, [availableVariantMap, workspaceKey])
|
|
|
|
useEffect(() => {
|
|
workspaceRef.current = presentWorkspace
|
|
}, [presentWorkspace])
|
|
|
|
const syncMaterialViewportRect = useCallback(() => {
|
|
const element = materialSelectionRef.current
|
|
if (!element) {
|
|
setMaterialViewportRect((current) => (current ? null : current))
|
|
return
|
|
}
|
|
|
|
const nextRect = cloneDockAnchorRect(element.getBoundingClientRect())
|
|
setMaterialViewportRect((current) =>
|
|
areDockAnchorRectsEqual(current, nextRect) ? current : nextRect,
|
|
)
|
|
}, [])
|
|
|
|
useLayoutEffect(() => {
|
|
syncMaterialViewportRect()
|
|
|
|
const element = materialSelectionRef.current
|
|
if (!element) {
|
|
return
|
|
}
|
|
|
|
const handleViewportChange = () => {
|
|
syncMaterialViewportRect()
|
|
}
|
|
|
|
window.addEventListener('resize', handleViewportChange)
|
|
window.addEventListener('scroll', handleViewportChange, true)
|
|
|
|
let observer: ResizeObserver | null = null
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
observer = new ResizeObserver(handleViewportChange)
|
|
observer.observe(element)
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleViewportChange)
|
|
window.removeEventListener('scroll', handleViewportChange, true)
|
|
observer?.disconnect()
|
|
}
|
|
}, [syncMaterialViewportRect])
|
|
|
|
useEffect(() => {
|
|
setSelectedNodeIds((current) =>
|
|
current.filter((nodeId) => presentWorkspace.nodes.some((node) => node.nodeId === nodeId)),
|
|
)
|
|
}, [presentWorkspace.nodes])
|
|
|
|
const selectedNodes = presentWorkspace.nodes.filter((node) => selectedNodeIds.includes(node.nodeId))
|
|
const selectedSingleNode = selectedNodes.length === 1 ? selectedNodes[0] : null
|
|
const selectedNodeAssistFeedback = useMemo(
|
|
() =>
|
|
selectedSingleNode
|
|
? deriveAssemblyAssistFeedback(presentWorkspace, selectedSingleNode.nodeId)
|
|
: null,
|
|
[presentWorkspace, selectedSingleNode],
|
|
)
|
|
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],
|
|
)
|
|
|
|
const selectedInventoryItem = useMemo(() => {
|
|
if (!selectedInventoryId) return null
|
|
return (
|
|
presentWorkspace.inventory.find((item) => item.inventoryId === selectedInventoryId) ?? null
|
|
)
|
|
}, [presentWorkspace.inventory, selectedInventoryId])
|
|
|
|
const selectedInventoryVariants = useMemo(() => {
|
|
if (!selectedInventoryItem) return []
|
|
return availableVariantMap.get(selectedInventoryItem.familyId) ?? []
|
|
}, [selectedInventoryItem, availableVariantMap])
|
|
|
|
const availableVariantById = useMemo(
|
|
() => new Map(availableVariants.map((variant) => [variant.id, variant])),
|
|
[availableVariants],
|
|
)
|
|
|
|
const stageWorldNodes = useMemo<ModelViewerWorldNode[]>(() => {
|
|
return presentWorkspace.nodes.map((node) => ({
|
|
nodeName: node.nodeId,
|
|
modelUrl: node.modelUrl,
|
|
position: node.position,
|
|
rotation: node.rotation,
|
|
scale: node.scale,
|
|
source: node.source,
|
|
}))
|
|
}, [presentWorkspace.nodes])
|
|
|
|
const stageMaterialAssignments = useMemo(() => {
|
|
return presentWorkspace.nodes.flatMap((node) => {
|
|
const materialConfig = resolveAssistedAssemblyNodeMaterialConfig({
|
|
variant: availableVariantById.get(node.variantId),
|
|
nodeMaterialConfig: node.materialConfig,
|
|
})
|
|
|
|
if (!materialConfig) {
|
|
return []
|
|
}
|
|
|
|
return [
|
|
{
|
|
nodeName: node.nodeId,
|
|
material: materialConfig,
|
|
},
|
|
]
|
|
})
|
|
}, [availableVariantById, presentWorkspace.nodes])
|
|
|
|
const selectableNodeNames = useMemo(
|
|
() => presentWorkspace.nodes.map((node) => node.nodeId),
|
|
[presentWorkspace.nodes],
|
|
)
|
|
|
|
const syncWorkspace = useCallback(
|
|
(next: AssemblyWorkspaceSnapshot) => {
|
|
setPresentWorkspace(next)
|
|
workspaceRef.current = next
|
|
onChange(next)
|
|
},
|
|
[onChange],
|
|
)
|
|
|
|
const pushWorkspace = useCallback(
|
|
(next: AssemblyWorkspaceSnapshot) => {
|
|
const current = workspaceRef.current
|
|
if (areSnapshotsEqual(current, next)) return
|
|
setUndoStack((prev) => [...prev, current])
|
|
setRedoStack([])
|
|
syncWorkspace(next)
|
|
},
|
|
[syncWorkspace],
|
|
)
|
|
|
|
const closeContextMenu = useCallback(() => {
|
|
setContextMenuState((current) => ({ ...current, visible: false }))
|
|
}, [])
|
|
|
|
const handleUndo = useCallback(() => {
|
|
setUndoStack((prev) => {
|
|
if (prev.length === 0) return prev
|
|
const current = workspaceRef.current
|
|
const next = prev[prev.length - 1]
|
|
setRedoStack((redoPrev) => [...redoPrev, current])
|
|
syncWorkspace(next)
|
|
return prev.slice(0, -1)
|
|
})
|
|
}, [syncWorkspace])
|
|
|
|
const handleRedo = useCallback(() => {
|
|
setRedoStack((prev) => {
|
|
if (prev.length === 0) return prev
|
|
const current = workspaceRef.current
|
|
const next = prev[prev.length - 1]
|
|
setUndoStack((undoPrev) => [...undoPrev, current])
|
|
syncWorkspace(next)
|
|
return prev.slice(0, -1)
|
|
})
|
|
}, [syncWorkspace])
|
|
|
|
useEffect(() => {
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === 'z') {
|
|
event.preventDefault()
|
|
handleUndo()
|
|
}
|
|
if (
|
|
(event.ctrlKey || event.metaKey) &&
|
|
((event.shiftKey && event.key.toLowerCase() === 'z') || event.key.toLowerCase() === 'y')
|
|
) {
|
|
event.preventDefault()
|
|
handleRedo()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown, true)
|
|
return () => {
|
|
window.removeEventListener('keydown', handleKeyDown, true)
|
|
}
|
|
}, [handleRedo, handleUndo])
|
|
|
|
useEffect(() => {
|
|
if (!contextMenuState.visible) return
|
|
|
|
function handleClose(event: PointerEvent | KeyboardEvent) {
|
|
if (event instanceof KeyboardEvent && event.key !== 'Escape') {
|
|
return
|
|
}
|
|
const isPointerLikeEvent =
|
|
typeof PointerEvent !== 'undefined'
|
|
? event instanceof PointerEvent
|
|
: 'pointerType' in event || 'button' in event
|
|
if (isPointerLikeEvent) {
|
|
const eventTarget =
|
|
event.target instanceof Element
|
|
? event.target
|
|
: event.target instanceof Node
|
|
? event.target.parentElement
|
|
: null
|
|
if (eventTarget?.closest('[data-context-menu-root="true"]')) {
|
|
return
|
|
}
|
|
}
|
|
closeContextMenu()
|
|
}
|
|
|
|
document.addEventListener('pointerdown', handleClose)
|
|
document.addEventListener('keydown', handleClose)
|
|
return () => {
|
|
document.removeEventListener('pointerdown', handleClose)
|
|
document.removeEventListener('keydown', handleClose)
|
|
}
|
|
}, [closeContextMenu, contextMenuState.visible])
|
|
|
|
const handleTransformStart = useCallback(
|
|
(nodeId: string) => {
|
|
if (pendingInteractiveNodeIdRef.current === nodeId) return
|
|
pendingInteractiveSnapshotRef.current = workspaceRef.current
|
|
pendingInteractiveNodeIdRef.current = nodeId
|
|
pendingInteractiveModeRef.current = gizmoMode
|
|
},
|
|
[gizmoMode],
|
|
)
|
|
|
|
const handleWorldNodeTransformChange = useCallback(
|
|
(payload: {
|
|
nodeName: string
|
|
position: [number, number, number]
|
|
rotation: [number, number, number]
|
|
}) => {
|
|
let next = updateAssemblyNodeTransform(workspaceRef.current, payload.nodeName, {
|
|
position: payload.position,
|
|
rotation: payload.rotation,
|
|
})
|
|
if (pendingInteractiveModeRef.current === 'translate') {
|
|
const baselineNode = pendingInteractiveSnapshotRef.current?.nodes.find(
|
|
(node) => node.nodeId === payload.nodeName,
|
|
)
|
|
next = applyAutomaticAssemblySnapPreview(next, payload.nodeName, {
|
|
fallbackZone: baselineNode?.zone,
|
|
})
|
|
}
|
|
if (!areSnapshotsEqual(workspaceRef.current, next)) {
|
|
setPresentWorkspace(next)
|
|
workspaceRef.current = next
|
|
}
|
|
},
|
|
[],
|
|
)
|
|
|
|
const handleTransformEnd = useCallback(
|
|
(nodeId: string) => {
|
|
const baseline = pendingInteractiveSnapshotRef.current
|
|
const baselineNodeId = pendingInteractiveNodeIdRef.current
|
|
const interactionMode = pendingInteractiveModeRef.current
|
|
pendingInteractiveSnapshotRef.current = null
|
|
pendingInteractiveNodeIdRef.current = null
|
|
pendingInteractiveModeRef.current = null
|
|
if (!baseline || baselineNodeId !== nodeId) return
|
|
|
|
let next = workspaceRef.current
|
|
if (interactionMode === 'translate') {
|
|
const baselineNode = baseline.nodes.find((node) => node.nodeId === nodeId)
|
|
next = applyAutomaticAssemblySnapPreview(next, nodeId, {
|
|
fallbackZone: baselineNode?.zone,
|
|
})
|
|
next = attemptSnapNode(next, nodeId)
|
|
}
|
|
|
|
if (!areSnapshotsEqual(workspaceRef.current, next)) {
|
|
syncWorkspace(next)
|
|
}
|
|
|
|
if (!areSnapshotsEqual(baseline, next)) {
|
|
setUndoStack((prev) => [...prev, baseline])
|
|
setRedoStack([])
|
|
}
|
|
},
|
|
[syncWorkspace],
|
|
)
|
|
|
|
const handleMoveNodes = useCallback(
|
|
(nodeIds: string[], zone: 'main' | 'staging' | 'subassembly') => {
|
|
const next = moveAssemblyNodes(workspaceRef.current, nodeIds, zone)
|
|
pushWorkspace(next)
|
|
},
|
|
[pushWorkspace],
|
|
)
|
|
|
|
const handleRemoveNodes = useCallback(
|
|
(nodeIds: string[]) => {
|
|
const next = removeAssemblyNodes(workspaceRef.current, nodeIds)
|
|
pushWorkspace(next)
|
|
setSelectedNodeIds([])
|
|
closeContextMenu()
|
|
},
|
|
[closeContextMenu, pushWorkspace],
|
|
)
|
|
|
|
const handleVariantActivate = useCallback(
|
|
(inventoryId: string, variant: AssistedAssemblyVariant) => {
|
|
const current = workspaceRef.current
|
|
const inventoryItem = current.inventory.find((item) => item.inventoryId === inventoryId)
|
|
if (!inventoryItem) return
|
|
|
|
const withSelection =
|
|
inventoryItem.selectedVariantId === variant.id
|
|
? current
|
|
: updateInventoryVariantSelection(current, inventoryId, variant.id)
|
|
|
|
const next = instantiateAssemblyNode(withSelection, {
|
|
inventoryId,
|
|
variant: toOpenProductVariant(variant),
|
|
})
|
|
|
|
pushWorkspace(next)
|
|
const latestNode = next.nodes[next.nodes.length - 1]
|
|
suppressEmptySelectionUntilRef.current = Date.now() + 320
|
|
setSelectedNodeIds(latestNode ? [latestNode.nodeId] : [])
|
|
setSelectedInventoryId(inventoryId)
|
|
setActivePreset(null)
|
|
closeContextMenu()
|
|
},
|
|
[closeContextMenu, pushWorkspace],
|
|
)
|
|
|
|
const openMaterialDock = useCallback(() => {
|
|
setIsMaterialDockOpen(true)
|
|
setSelectedInventoryId((current) => current ?? preferredInventoryId)
|
|
syncMaterialViewportRect()
|
|
}, [preferredInventoryId, syncMaterialViewportRect])
|
|
|
|
const closeMaterialDock = useCallback(() => {
|
|
setIsMaterialDockOpen(false)
|
|
setSelectedInventoryId(null)
|
|
setActiveMaterialAnchorRect(null)
|
|
}, [])
|
|
|
|
const handleActiveMaterialAnchorChange = useCallback(
|
|
({
|
|
inventoryId,
|
|
rect,
|
|
}: {
|
|
inventoryId: string | null
|
|
rect: AssemblyMaterialDockAnchorRect | null
|
|
}) => {
|
|
if (inventoryId !== (selectedInventoryId ?? preferredInventoryId)) {
|
|
return
|
|
}
|
|
|
|
setActiveMaterialAnchorRect((current) =>
|
|
areDockAnchorRectsEqual(current, rect) ? current : rect,
|
|
)
|
|
},
|
|
[preferredInventoryId, selectedInventoryId],
|
|
)
|
|
|
|
const handleNodeSelect = useCallback(
|
|
(payload: {
|
|
nodeName: string | null
|
|
rawNodeName: string | null
|
|
}) => {
|
|
const nextNodeId = payload.nodeName ?? payload.rawNodeName ?? null
|
|
if (!nextNodeId && Date.now() < suppressEmptySelectionUntilRef.current) {
|
|
return
|
|
}
|
|
|
|
closeContextMenu()
|
|
setSelectedNodeIds(nextNodeId ? [nextNodeId] : [])
|
|
},
|
|
[closeContextMenu],
|
|
)
|
|
|
|
const handleNodeContextMenu = useCallback(
|
|
(payload: {
|
|
nodeName: string | null
|
|
rawNodeName: string | null
|
|
clientX: number
|
|
clientY: number
|
|
hasRaycastHit?: boolean
|
|
}) => {
|
|
const fallbackSelectedNodeId =
|
|
!payload.nodeName &&
|
|
!payload.rawNodeName &&
|
|
payload.hasRaycastHit &&
|
|
selectedNodeIds.length === 1
|
|
? selectedNodeIds[0]
|
|
: null
|
|
const nodeId = payload.nodeName ?? payload.rawNodeName ?? fallbackSelectedNodeId ?? null
|
|
if (!nodeId) {
|
|
closeContextMenu()
|
|
return
|
|
}
|
|
|
|
setSelectedNodeIds([nodeId])
|
|
setContextMenuState({
|
|
visible: true,
|
|
x: payload.clientX,
|
|
y: payload.clientY,
|
|
nodeId,
|
|
})
|
|
},
|
|
[closeContextMenu, selectedNodeIds],
|
|
)
|
|
|
|
const contextMenuNode =
|
|
presentWorkspace.nodes.find((node) => node.nodeId === contextMenuState.nodeId) ?? selectedSingleNode
|
|
const contextMenuGroups = useMemo<ContextMenuGroup[]>(() => {
|
|
if (!contextMenuNode) return []
|
|
|
|
const isMainZone = contextMenuNode.zone === 'main'
|
|
return [
|
|
{
|
|
id: 'assembly-node-actions',
|
|
items: [
|
|
{
|
|
id: isMainZone ? 'move-node-staging' : 'move-node-main',
|
|
label: isMainZone ? '移到待装区' : '移到主装配',
|
|
},
|
|
{
|
|
id: 'remove-node',
|
|
label: '移除 3D 世界',
|
|
},
|
|
],
|
|
},
|
|
]
|
|
}, [contextMenuNode])
|
|
const contextMenuPosition = useMemo(
|
|
() => calculateMenuPosition(contextMenuState.x, contextMenuState.y, 196, 116),
|
|
[contextMenuState.x, contextMenuState.y],
|
|
)
|
|
|
|
const handleContextMenuItemClick = useCallback(
|
|
(item: ContextMenuItem) => {
|
|
if (!contextMenuNode) {
|
|
return
|
|
}
|
|
|
|
if (item.id === 'move-node-main') {
|
|
handleMoveNodes([contextMenuNode.nodeId], 'main')
|
|
closeContextMenu()
|
|
return
|
|
}
|
|
|
|
if (item.id === 'move-node-staging') {
|
|
handleMoveNodes([contextMenuNode.nodeId], 'staging')
|
|
closeContextMenu()
|
|
return
|
|
}
|
|
|
|
if (item.id === 'remove-node') {
|
|
handleRemoveNodes([contextMenuNode.nodeId])
|
|
}
|
|
},
|
|
[closeContextMenu, contextMenuNode, handleMoveNodes, handleRemoveNodes],
|
|
)
|
|
|
|
const materialSelectionZone = (
|
|
<div
|
|
ref={materialSelectionRef}
|
|
onMouseEnter={openMaterialDock}
|
|
onMouseLeave={closeMaterialDock}
|
|
className={cn(
|
|
'relative h-full transition-[width] duration-200 ease-out',
|
|
isMaterialDockOpen ? 'w-[452px]' : 'w-3',
|
|
)}
|
|
>
|
|
<div className="absolute inset-y-0 left-0 flex items-center">
|
|
<div className="h-[92px] w-[10px] rounded-full bg-white/74 shadow-[0_12px_32px_rgba(15,23,42,0.16)] backdrop-blur-xl" />
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
'absolute inset-y-0 left-0 flex w-[452px] items-stretch gap-3 pl-3 pr-3 transition-all duration-200 ease-out',
|
|
isMaterialDockOpen
|
|
? 'translate-x-0 opacity-100'
|
|
: 'pointer-events-none -translate-x-4 opacity-0',
|
|
)}
|
|
>
|
|
<AssemblyMaterialDock
|
|
items={presentWorkspace.inventory}
|
|
availableVariantMap={availableVariantMap}
|
|
activeInventoryId={selectedInventoryId ?? preferredInventoryId}
|
|
onSelect={(inventoryId) => {
|
|
setSelectedInventoryId(inventoryId)
|
|
}}
|
|
onActiveAnchorChange={handleActiveMaterialAnchorChange}
|
|
className="h-full pt-1"
|
|
/>
|
|
|
|
<div className="relative flex-1">
|
|
{selectedInventoryItem ? (
|
|
<AssemblyMaterialCard
|
|
item={selectedInventoryItem}
|
|
variants={selectedInventoryVariants}
|
|
visible={Boolean(selectedInventoryItem)}
|
|
onVariantActivate={handleVariantActivate}
|
|
anchorRect={activeMaterialAnchorRect}
|
|
viewportRect={materialViewportRect}
|
|
safeTop={10}
|
|
safeBottom={(materialViewportRect?.height ?? 0) - 10}
|
|
className="left-0"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const rightDrawer = rightDrawerContent ? (
|
|
<div
|
|
onMouseEnter={() => setIsRightDrawerOpen(true)}
|
|
onMouseLeave={() => setIsRightDrawerOpen(false)}
|
|
className={cn(
|
|
'relative h-full max-h-full overflow-visible transition-[width] duration-200 ease-out',
|
|
isRightDrawerOpen ? 'w-[380px]' : 'w-6',
|
|
)}
|
|
>
|
|
<div className="absolute inset-y-0 right-0 flex w-6 items-center justify-end">
|
|
<div className="flex h-[96px] w-4 items-center justify-center rounded-full bg-white/76 shadow-[0_10px_28px_rgba(15,23,42,0.16)] backdrop-blur-xl">
|
|
<div className="h-9 w-px bg-apple-separator/60" />
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
'absolute inset-y-0 right-4 w-[360px] overflow-hidden rounded-[30px] border border-white/58 bg-white/84 shadow-[0_18px_38px_rgba(15,23,42,0.14)] backdrop-blur-2xl transition-all duration-200 ease-out',
|
|
isRightDrawerOpen
|
|
? 'translate-x-0 opacity-100'
|
|
: 'pointer-events-none translate-x-[calc(100%+16px)] opacity-0',
|
|
)}
|
|
>
|
|
<div className="h-full max-h-full overflow-y-auto p-4">
|
|
{warnings.length > 0 || selectedNodeAssistFeedback ? (
|
|
<div className="mb-3 space-y-2">
|
|
{selectedNodeAssistFeedback ? (
|
|
<div
|
|
className={cn(
|
|
'rounded-2xl border px-3 py-2',
|
|
selectedNodeAssistFeedback.tone === 'warning'
|
|
? 'border-amber-200/70 bg-amber-50 text-amber-700'
|
|
: selectedNodeAssistFeedback.tone === 'success'
|
|
? 'border-emerald-200/70 bg-emerald-50 text-emerald-700'
|
|
: 'border-sky-200/70 bg-sky-50 text-sky-700',
|
|
)}
|
|
>
|
|
<p className="text-xs font-medium">{selectedNodeAssistFeedback.title}</p>
|
|
<p className="mt-1 text-xs leading-5">{selectedNodeAssistFeedback.description}</p>
|
|
</div>
|
|
) : null}
|
|
{warnings.map((warning) => (
|
|
<p
|
|
key={warning}
|
|
className="rounded-2xl border border-apple-separator/20 bg-white/70 px-3 py-2 text-xs leading-5 text-apple-label-secondary"
|
|
>
|
|
{warning}
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{rightDrawerContent}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null
|
|
|
|
return (
|
|
<div className={cn('relative h-full w-full', className)}>
|
|
<ModelPreviewPanel
|
|
key={workspaceKey ?? 'assisted-assembly-workspace'}
|
|
modelUrl={null}
|
|
worldNodes={stageWorldNodes}
|
|
worldFrameMode="initial"
|
|
title={title ?? candidateName}
|
|
showFpsBadge={false}
|
|
className="h-full"
|
|
defaultShowSceneGrid={showSceneGrid}
|
|
showSceneGrid={showSceneGrid}
|
|
materialAssignments={stageMaterialAssignments}
|
|
onToggleSceneGrid={() => setShowSceneGrid((current) => !current)}
|
|
showCameraActionButtons
|
|
enableTranslateGizmo
|
|
gizmoMode={gizmoMode}
|
|
onGizmoModeChange={setGizmoMode}
|
|
activePreset={activePreset}
|
|
onPresetChange={setActivePreset}
|
|
onFitToScene={() => setActivePreset(null)}
|
|
onResetView={() => setActivePreset(null)}
|
|
selectedNodeName={selectedNodeIds[0] ?? null}
|
|
selectableNodeNames={selectableNodeNames}
|
|
onNodeSelect={handleNodeSelect}
|
|
onNodeContextMenu={handleNodeContextMenu}
|
|
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}
|
|
overlayRightClassName="right-0 top-[88px] bottom-6"
|
|
/>
|
|
|
|
{contextMenuState.visible && contextMenuNode ? (
|
|
<ContextMenu
|
|
visible={contextMenuState.visible}
|
|
position={contextMenuPosition}
|
|
title={contextMenuNode.name}
|
|
subtitle={getSourceLabel(contextMenuNode.source)}
|
|
groups={contextMenuGroups}
|
|
onItemClick={handleContextMenuItemClick}
|
|
onClose={closeContextMenu}
|
|
width={196}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AssemblyWorkbenchV2
|