2026-04-17 12:50:06 +08:00

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