feat: refine installation demo numbering preview
This commit is contained in:
parent
6c407f208b
commit
a789124f10
@ -183,7 +183,6 @@ export function InstallationDemoShareViewerClient({
|
||||
() => Array.from(new Set(numberingModelParts.flatMap((part) => getPartNodeNames(part)))),
|
||||
[numberingModelParts],
|
||||
)
|
||||
const numberingVisibleNodeSignature = numberingVisibleNodeNames.join('\u001f')
|
||||
const storedStepMaterialSnapshot =
|
||||
activeStep ? snapshot.stepMaterialsByStepId[activeStep.id] ?? null : null
|
||||
|
||||
@ -343,27 +342,6 @@ export function InstallationDemoShareViewerClient({
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeStage.kind, numberingModelParts.length, numberingRevealCount, playing])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModelReady || preview.stageView !== 'numbering' || !previewEngine) return
|
||||
if (numberingVisibleNodeNames.length === 0) return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const framed = previewEngine.fitToNodeNames(numberingVisibleNodeNames, 1.08)
|
||||
if (!framed) {
|
||||
previewEngine.fitToView(1.08)
|
||||
}
|
||||
}, 180)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [
|
||||
isModelReady,
|
||||
numberingVisibleNodeNames,
|
||||
numberingVisibleNodeNames.length,
|
||||
numberingVisibleNodeSignature,
|
||||
preview.stageView,
|
||||
previewEngine,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isModelReady || !playing || !activeStage) return
|
||||
const timer = window.setTimeout(() => {
|
||||
@ -517,6 +495,7 @@ export function InstallationDemoShareViewerClient({
|
||||
stepMaterialMarkerParts={previewStepMaterialMarkerParts}
|
||||
viewportInstanceKey={shareViewportInstanceKey}
|
||||
showStepModeToggle={false}
|
||||
showNumberingToolbar={false}
|
||||
showStatusBar={false}
|
||||
canEdit={false}
|
||||
gizmoMode="translate"
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import type { InstallationPartItem } from '../types'
|
||||
import { InstallationPartsInventoryBoard } from './InstallationPartsInventoryBoard'
|
||||
|
||||
interface InstallationNumberingPresentationProps {
|
||||
parts: InstallationPartItem[]
|
||||
thumbnailUrls: Record<string, string>
|
||||
snapshotUrls: Record<string, string>
|
||||
activePartId: string | null
|
||||
}
|
||||
|
||||
export function InstallationNumberingPresentation({
|
||||
parts,
|
||||
thumbnailUrls,
|
||||
snapshotUrls,
|
||||
activePartId,
|
||||
}: InstallationNumberingPresentationProps) {
|
||||
return (
|
||||
<div className="pointer-events-auto flex h-full w-full items-center justify-center overflow-hidden bg-[#f3f5f6] px-6 pb-28 pt-14 xl:px-10 max-md:items-start max-md:px-3 max-md:pb-32 max-md:pt-16">
|
||||
<InstallationPartsInventoryBoard
|
||||
parts={parts}
|
||||
thumbnailUrls={thumbnailUrls}
|
||||
snapshotUrls={snapshotUrls}
|
||||
activePartId={activePartId}
|
||||
presentation
|
||||
animateCells
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -4,11 +4,16 @@ import { getPartNodeNames } from '../lib/installationPartCatalogHelpers'
|
||||
interface InstallationPartsInventoryBoardProps {
|
||||
parts: InstallationPartItem[]
|
||||
thumbnailUrls?: Record<string, string>
|
||||
snapshotUrls?: Record<string, string>
|
||||
visiblePartIds?: string[]
|
||||
activePartId?: string | null
|
||||
title?: string
|
||||
compact?: boolean
|
||||
presentation?: boolean
|
||||
animateCells?: boolean
|
||||
showTitle?: boolean
|
||||
snapshotUrl?: string | null
|
||||
onSelectPart?: (partId: string) => void
|
||||
}
|
||||
|
||||
export function resolveInstallationPartDisplayQuantity(part: InstallationPartItem) {
|
||||
@ -127,40 +132,53 @@ export function InstallationNumberingSnapshotFigure({
|
||||
export function InstallationPartsInventoryBoard({
|
||||
parts,
|
||||
thumbnailUrls = {},
|
||||
snapshotUrls = {},
|
||||
visiblePartIds,
|
||||
activePartId = null,
|
||||
title = '配件',
|
||||
compact = false,
|
||||
presentation = false,
|
||||
animateCells = false,
|
||||
showTitle = true,
|
||||
snapshotUrl = null,
|
||||
onSelectPart,
|
||||
}: InstallationPartsInventoryBoardProps) {
|
||||
const visiblePartIdSet = visiblePartIds ? new Set(visiblePartIds) : null
|
||||
const visibleParts = visiblePartIdSet
|
||||
? parts.filter((part) => visiblePartIdSet.has(part.id))
|
||||
: parts
|
||||
const highlightedPartIdSet = visiblePartIds ? new Set(visiblePartIds) : null
|
||||
const visibleParts = parts
|
||||
|
||||
if (visibleParts.length === 0) return null
|
||||
|
||||
const panelClassName = compact
|
||||
? 'pointer-events-auto w-full max-w-[680px] rounded-[8px] bg-white/96 p-1.5 shadow-[0_10px_30px_rgba(45,51,53,0.12)] ring-1 ring-black/[0.1]'
|
||||
: 'pointer-events-auto w-[min(920px,calc(100vw-560px))] max-w-[calc(100vw-32px)] rounded-[10px] bg-white/96 p-3 shadow-[0_20px_70px_rgba(45,51,53,0.18)] ring-1 ring-black/[0.1] backdrop-blur-xl'
|
||||
const headerClassName = compact
|
||||
? 'flex h-6 items-center bg-[#1f2526] px-3 text-[11px] font-black tracking-[0.06em] text-white'
|
||||
: 'flex h-10 items-center bg-[#1f2526] px-4 text-[16px] font-black tracking-[0.08em] text-white'
|
||||
const gridClassName = compact
|
||||
? 'grid max-h-[116px] grid-cols-4 overflow-y-auto border-l border-t border-[#2d3335]/20 bg-white xl:grid-cols-5'
|
||||
: 'grid max-h-[min(66vh,620px)] grid-cols-2 overflow-y-auto border-l border-t border-[#2d3335]/20 bg-white sm:grid-cols-3 xl:grid-cols-4'
|
||||
const cellClassName = compact
|
||||
? 'relative min-h-[86px] border-b border-r border-[#2d3335]/20 bg-[#fbfbfa]'
|
||||
: 'relative min-h-[150px] border-b border-r border-[#2d3335]/20 bg-[#fbfbfa]'
|
||||
const imageWrapClassName = compact
|
||||
? 'flex h-[54px] items-center justify-center px-6 pt-4'
|
||||
: 'flex h-[112px] items-center justify-center px-8 pt-5'
|
||||
const panelClassName = presentation
|
||||
? 'pointer-events-auto w-full overflow-hidden rounded-[8px] bg-white p-2 shadow-[0_20px_70px_rgba(45,51,53,0.16)] ring-1 ring-black/[0.12]'
|
||||
: compact
|
||||
? 'pointer-events-auto w-full max-w-[680px] rounded-[8px] bg-white/96 p-1.5 shadow-[0_10px_30px_rgba(45,51,53,0.12)] ring-1 ring-black/[0.1]'
|
||||
: 'pointer-events-auto w-full overflow-hidden rounded-[10px] bg-white/96 p-2.5 shadow-[0_20px_70px_rgba(45,51,53,0.18)] ring-1 ring-black/[0.1] backdrop-blur-xl'
|
||||
const headerClassName = presentation
|
||||
? 'flex h-10 items-center overflow-hidden bg-[#1f2526] px-4 text-[15px] font-black tracking-[0.08em] text-white'
|
||||
: compact
|
||||
? 'flex h-6 items-center bg-[#1f2526] px-3 text-[11px] font-black tracking-[0.06em] text-white'
|
||||
: 'flex h-9 items-center overflow-hidden bg-[#1f2526] px-4 text-[15px] font-black tracking-[0.08em] text-white'
|
||||
const gridClassName = presentation
|
||||
? 'grid max-h-[min(72vh,760px)] grid-cols-2 overflow-y-auto border-l border-t border-[#2d3335]/20 bg-white sm:grid-cols-3 xl:grid-cols-4 max-md:flex max-md:max-h-[184px] max-md:snap-x max-md:overflow-x-auto max-md:overflow-y-hidden'
|
||||
: compact
|
||||
? 'grid max-h-[116px] grid-cols-4 overflow-y-auto border-l border-t border-[#2d3335]/20 bg-white xl:grid-cols-5'
|
||||
: 'grid max-h-[min(58vh,620px)] grid-cols-2 overflow-y-auto border-l border-t border-[#2d3335]/20 bg-white sm:grid-cols-3 2xl:grid-cols-4 max-md:flex max-md:max-h-[180px] max-md:snap-x max-md:overflow-x-auto max-md:overflow-y-hidden'
|
||||
const baseCellClassName = presentation
|
||||
? 'relative min-h-[190px] border-b border-r border-[#2d3335]/20 bg-[#fbfbfa] text-left xl:min-h-[220px] max-md:min-h-[166px] max-md:w-[152px] max-md:shrink-0 max-md:snap-start'
|
||||
: compact
|
||||
? 'relative min-h-[86px] border-b border-r border-[#2d3335]/20 bg-[#fbfbfa]'
|
||||
: 'relative min-h-[148px] border-b border-r border-[#2d3335]/20 bg-[#fbfbfa] text-left transition max-md:min-h-[154px] max-md:w-[142px] max-md:shrink-0 max-md:snap-start'
|
||||
const imageWrapClassName = presentation
|
||||
? 'flex h-[148px] items-center justify-center px-8 pt-6 xl:h-[178px] max-md:h-[124px] max-md:px-5 max-md:pt-5'
|
||||
: compact
|
||||
? 'flex h-[54px] items-center justify-center px-6 pt-4'
|
||||
: 'flex h-[108px] items-center justify-center px-7 pt-5 max-md:h-[112px] max-md:px-5'
|
||||
const fallbackClassName = compact
|
||||
? 'flex h-12 w-12 items-center justify-center rounded-[6px] bg-white text-[16px] font-black text-[#2d3335] ring-1 ring-black/[0.08]'
|
||||
: 'flex h-20 w-20 items-center justify-center rounded-[8px] bg-white text-[22px] font-black text-[#2d3335] ring-1 ring-black/[0.08]'
|
||||
const footerClassName = compact
|
||||
? 'flex min-h-7 items-center justify-between gap-1 border-t border-[#2d3335]/10 px-2 py-1'
|
||||
: 'flex min-h-9 items-center justify-between gap-2 border-t border-[#2d3335]/10 px-3 py-2'
|
||||
: 'flex min-h-10 items-center justify-between gap-2 border-t border-[#2d3335]/10 px-3 py-2'
|
||||
const nameClassName = compact
|
||||
? 'min-w-0 truncate text-[9px] font-bold text-[#5a6062]'
|
||||
: 'min-w-0 truncate text-[11px] font-bold text-[#5a6062]'
|
||||
@ -171,7 +189,23 @@ export function InstallationPartsInventoryBoard({
|
||||
|
||||
return (
|
||||
<div className={panelClassName}>
|
||||
{showTitle ? <div className={headerClassName}>{title}</div> : null}
|
||||
{animateCells ? (
|
||||
<style>{`
|
||||
@keyframes installation-numbering-cell-enter {
|
||||
from { opacity: 0; transform: translateY(18px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.installation-numbering-cell-enter { animation: none !important; }
|
||||
}
|
||||
`}</style>
|
||||
) : null}
|
||||
{showTitle ? (
|
||||
<div className={headerClassName}>
|
||||
<span>{title}</span>
|
||||
<span className="ml-auto h-full w-24 skew-x-[-18deg] border-l border-white/30 bg-white/10" />
|
||||
</div>
|
||||
) : null}
|
||||
{snapshotUrl ? (
|
||||
<div className="mb-1.5 overflow-hidden rounded-[6px] border border-[#2d3335]/15 bg-white">
|
||||
{snapshotPrimaryPart ? (
|
||||
@ -181,11 +215,36 @@ export function InstallationPartsInventoryBoard({
|
||||
) : null}
|
||||
{snapshotUrl ? null : (
|
||||
<div className={gridClassName}>
|
||||
{visibleParts.map((part) => {
|
||||
{visibleParts.map((part, index) => {
|
||||
const quantity = resolveInstallationPartDisplayQuantity(part)
|
||||
const thumbnailUrl = thumbnailUrls[part.id] ?? null
|
||||
const thumbnailUrl = snapshotUrls[part.id] ?? thumbnailUrls[part.id] ?? null
|
||||
const isActive =
|
||||
activePartId === part.id || Boolean(highlightedPartIdSet?.has(part.id))
|
||||
const cellClassName = [
|
||||
baseCellClassName,
|
||||
onSelectPart ? 'cursor-pointer hover:bg-white' : '',
|
||||
isActive ? 'z-10 ring-2 ring-[#e23d2c] ring-inset' : '',
|
||||
animateCells ? 'installation-numbering-cell-enter' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return (
|
||||
<div key={part.id} className={cellClassName}>
|
||||
<button
|
||||
key={part.id}
|
||||
type="button"
|
||||
className={cellClassName}
|
||||
onClick={() => onSelectPart?.(part.id)}
|
||||
aria-pressed={onSelectPart ? isActive : undefined}
|
||||
tabIndex={onSelectPart ? 0 : -1}
|
||||
style={
|
||||
animateCells
|
||||
? {
|
||||
animation: 'installation-numbering-cell-enter 650ms cubic-bezier(0.2, 0.75, 0.25, 1) both',
|
||||
animationDelay: `${Math.min(index, 12) * 140}ms`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="absolute left-2 top-2 z-10 flex flex-col items-start gap-1">
|
||||
<PartCodeBadge code={part.code} compact={compact} />
|
||||
<span
|
||||
@ -220,7 +279,7 @@ export function InstallationPartsInventoryBoard({
|
||||
{getPartNodeNames(part).length > 0 ? `${getPartNodeNames(part).length} 节点` : '手填'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, type ComponentProps } from 'react'
|
||||
import { AlertCircle, Box, ChevronDown, ChevronUp, ListChecks, MousePointer2 } from 'lucide-react'
|
||||
import { useRef, type ComponentProps } from 'react'
|
||||
import { AlertCircle, Box, MousePointer2 } from 'lucide-react'
|
||||
import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop'
|
||||
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
|
||||
import type { NodePositionOffset, NodeRotationState, ThreeDEngine } from '@/lib/3d/engine'
|
||||
@ -19,10 +19,8 @@ import {
|
||||
useNumberingOverlaySafeInsets,
|
||||
} from '../hooks/useNumberingOverlaySafeInsets'
|
||||
import { InstallationNumberingOverlay } from './InstallationNumberingOverlay'
|
||||
import {
|
||||
InstallationNumberingPartInfoCard,
|
||||
InstallationPartsInventoryBoard,
|
||||
} from './InstallationPartsInventoryBoard'
|
||||
import { InstallationNumberingPresentation } from './InstallationNumberingPresentation'
|
||||
import { InstallationPartsInventoryBoard } from './InstallationPartsInventoryBoard'
|
||||
import { InstallationStepMaterialCard } from './InstallationStepMaterialCard'
|
||||
import { NumberingPreviewToolbar } from './NumberingPreviewToolbar'
|
||||
import { SelectionModeToolbar } from './SelectionModeToolbar'
|
||||
@ -76,6 +74,7 @@ interface InstallationPreviewStageProps {
|
||||
stepMaterialMarkerParts: InstallationPartItem[]
|
||||
viewportInstanceKey?: string
|
||||
showStepModeToggle?: boolean
|
||||
showNumberingToolbar?: boolean
|
||||
showStatusBar?: boolean
|
||||
canEdit: boolean
|
||||
gizmoMode: 'translate' | 'rotate'
|
||||
@ -134,6 +133,7 @@ export function InstallationPreviewStage({
|
||||
stepMaterialMarkerParts,
|
||||
viewportInstanceKey,
|
||||
showStepModeToggle = true,
|
||||
showNumberingToolbar = true,
|
||||
showStatusBar = true,
|
||||
canEdit,
|
||||
gizmoMode,
|
||||
@ -157,7 +157,6 @@ export function InstallationPreviewStage({
|
||||
onNextNumbering,
|
||||
onResetNumbering,
|
||||
}: InstallationPreviewStageProps) {
|
||||
const [numberingInventoryExpanded, setNumberingInventoryExpanded] = useState(false)
|
||||
const stageRef = useRef<HTMLElement | null>(null)
|
||||
const numberingToolbarRef = useRef<HTMLDivElement | null>(null)
|
||||
const numberingInventoryPanelRef = useRef<HTMLDivElement | null>(null)
|
||||
@ -169,16 +168,11 @@ export function InstallationPreviewStage({
|
||||
const showStepMaterials =
|
||||
(view === 'steps' || view === 'preview') && stepMaterialItems.length > 0
|
||||
const showExplodedNumberingOverlay = view === 'exploded' && numberingParts.length > 0
|
||||
const showNumberingInventoryBoard = view === 'numbering' && numberingParts.length > 0
|
||||
const numberingInventoryCount =
|
||||
numberingVisiblePartIds.length > 0 ? numberingVisiblePartIds.length : numberingParts.length
|
||||
const numberingInventoryPanelWidthClassName = numberingInventoryExpanded
|
||||
? 'w-[min(520px,72%)] max-md:w-[92%]'
|
||||
: 'w-[184px]'
|
||||
const showNumberingPresentation =
|
||||
view === 'numbering' && numberingParts.length > 0 && (!canEdit || mode === 'preview')
|
||||
const showNumberingInventoryBoard =
|
||||
view === 'numbering' && numberingParts.length > 0 && !showNumberingPresentation
|
||||
const activeNumberingPartId = numberingVisiblePartIds[0] ?? selectedNumberingPartId
|
||||
const activeNumberingSnapshotUrl = activeNumberingPartId
|
||||
? numberingSnapshotUrls[activeNumberingPartId] ?? null
|
||||
: null
|
||||
const activeNumberingPart = activeNumberingPartId
|
||||
? numberingParts.find((part) => part.id === activeNumberingPartId) ?? null
|
||||
: null
|
||||
@ -218,21 +212,21 @@ export function InstallationPreviewStage({
|
||||
view === 'preview' ? mode : 'edit',
|
||||
].join(':')
|
||||
const measuredNumberingSafeInsets = useNumberingOverlaySafeInsets({
|
||||
enabled: view === 'numbering',
|
||||
enabled: showNumberingInventoryBoard,
|
||||
stageRef,
|
||||
topOverlayRef: numberingToolbarRef,
|
||||
rightOverlayRef: numberingInventoryPanelRef,
|
||||
bottomOverlayRef: statusBarRef,
|
||||
leftOverlayRef: numberingInfoCardRef,
|
||||
refreshKey: [
|
||||
numberingInventoryExpanded,
|
||||
showStatusBar,
|
||||
showNumberingInventoryBoard,
|
||||
numberingParts.length,
|
||||
activeNumberingPartId ?? 'none',
|
||||
].join(':'),
|
||||
})
|
||||
const numberingOverlaySafeInsets =
|
||||
view === 'numbering' ? measuredNumberingSafeInsets : DEFAULT_NUMBERING_SAFE_INSETS
|
||||
showNumberingInventoryBoard ? measuredNumberingSafeInsets : DEFAULT_NUMBERING_SAFE_INSETS
|
||||
|
||||
return (
|
||||
<section ref={stageRef} className="relative h-full min-h-0 min-w-0 flex-1 bg-[#f1f4f5]">
|
||||
@ -244,8 +238,8 @@ export function InstallationPreviewStage({
|
||||
repairDisplayNormals
|
||||
showFpsBadge={false}
|
||||
className="h-full"
|
||||
viewportControlsEnabled={view !== 'preview'}
|
||||
showCameraActionButtons
|
||||
viewportControlsEnabled={view !== 'preview' && !showNumberingPresentation}
|
||||
showCameraActionButtons={!showNumberingPresentation}
|
||||
enableTranslateGizmo={explosionEditingEnabled}
|
||||
gizmoMode={gizmoMode}
|
||||
onGizmoModeChange={onGizmoModeChange}
|
||||
@ -276,13 +270,16 @@ export function InstallationPreviewStage({
|
||||
disabled={!canEdit || !modelUrl}
|
||||
/>
|
||||
) : null}
|
||||
{view === 'numbering' ? (
|
||||
{view === 'numbering' && showNumberingToolbar ? (
|
||||
<div ref={numberingToolbarRef}>
|
||||
<NumberingPreviewToolbar
|
||||
mode={mode}
|
||||
playing={numberingPlaying}
|
||||
canPreview={numberingTotalCount > 0}
|
||||
currentIndex={numberingCurrentIndex}
|
||||
totalCount={numberingTotalCount}
|
||||
showModeToggle={canEdit}
|
||||
onModeChange={onModeChange}
|
||||
onTogglePlaying={onToggleNumberingPlaying}
|
||||
onPrevious={onPreviousNumbering}
|
||||
onNext={onNextNumbering}
|
||||
@ -306,7 +303,14 @@ export function InstallationPreviewStage({
|
||||
</div>
|
||||
}
|
||||
overlayCenter={
|
||||
showNumberingInventoryBoard ? (
|
||||
showNumberingPresentation ? (
|
||||
<InstallationNumberingPresentation
|
||||
parts={numberingParts}
|
||||
thumbnailUrls={numberingPartThumbnailUrls}
|
||||
snapshotUrls={numberingSnapshotUrls}
|
||||
activePartId={activeNumberingPartId}
|
||||
/>
|
||||
) : showNumberingInventoryBoard ? (
|
||||
<>
|
||||
<InstallationNumberingOverlay
|
||||
engine={previewEngine}
|
||||
@ -319,40 +323,16 @@ export function InstallationPreviewStage({
|
||||
/>
|
||||
<div
|
||||
ref={numberingInventoryPanelRef}
|
||||
className={`pointer-events-auto absolute right-4 top-16 min-w-[180px] max-w-[92%] max-md:right-3 max-md:top-14 ${numberingInventoryPanelWidthClassName}`}
|
||||
className="pointer-events-auto absolute right-4 top-16 w-[min(620px,42vw)] max-w-[calc(100vw-32px)] max-md:left-3 max-md:right-3 max-md:top-14 max-md:w-auto"
|
||||
>
|
||||
<div className="overflow-hidden rounded-[8px] bg-white/94 shadow-[0_12px_34px_rgba(45,51,53,0.14)] ring-1 ring-black/[0.1] backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center justify-between gap-2 px-3 text-left text-[12px] font-black text-[#222829] transition hover:bg-[#f5f6f6]"
|
||||
onClick={() => setNumberingInventoryExpanded((expanded) => !expanded)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ListChecks size={15} strokeWidth={2} className="shrink-0 text-[#2d3335]" />
|
||||
<span className="truncate">配件清单</span>
|
||||
<span className="shrink-0 rounded-full bg-[#edf1f2] px-2 py-0.5 text-[10px] font-bold text-[#6a7072]">
|
||||
{numberingInventoryCount} 项
|
||||
</span>
|
||||
</span>
|
||||
{numberingInventoryExpanded ? (
|
||||
<ChevronUp size={15} strokeWidth={2} className="shrink-0 text-[#6a7072]" />
|
||||
) : (
|
||||
<ChevronDown size={15} strokeWidth={2} className="shrink-0 text-[#6a7072]" />
|
||||
)}
|
||||
</button>
|
||||
{numberingInventoryExpanded ? (
|
||||
<div className="border-t border-[#2d3335]/10 p-1.5">
|
||||
<InstallationPartsInventoryBoard
|
||||
compact
|
||||
showTitle={false}
|
||||
parts={numberingParts}
|
||||
thumbnailUrls={numberingPartThumbnailUrls}
|
||||
visiblePartIds={numberingVisiblePartIds}
|
||||
snapshotUrl={activeNumberingSnapshotUrl}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<InstallationPartsInventoryBoard
|
||||
parts={numberingParts}
|
||||
thumbnailUrls={numberingPartThumbnailUrls}
|
||||
snapshotUrls={numberingSnapshotUrls}
|
||||
visiblePartIds={numberingVisiblePartIds}
|
||||
activePartId={activeNumberingPartId}
|
||||
onSelectPart={onSelectPart}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : showExplodedNumberingOverlay ? (
|
||||
@ -374,15 +354,7 @@ export function InstallationPreviewStage({
|
||||
) : null
|
||||
}
|
||||
overlayLeft={
|
||||
view === 'numbering' && activeNumberingPart ? (
|
||||
<div ref={numberingInfoCardRef}>
|
||||
<InstallationNumberingPartInfoCard
|
||||
part={activeNumberingPart}
|
||||
currentIndex={numberingCurrentIndex}
|
||||
totalCount={numberingTotalCount}
|
||||
/>
|
||||
</div>
|
||||
) : showStepMaterials ? (
|
||||
showStepMaterials ? (
|
||||
<InstallationStepMaterialCard
|
||||
step={activeStep}
|
||||
items={stepMaterialItems}
|
||||
@ -392,7 +364,7 @@ export function InstallationPreviewStage({
|
||||
}
|
||||
overlayLeftClassName="left-6 top-6 h-auto max-md:left-3 max-md:top-3"
|
||||
overlayBottomCenter={
|
||||
showStatusBar ? (
|
||||
showStatusBar && !showNumberingPresentation ? (
|
||||
<div
|
||||
ref={statusBarRef}
|
||||
className="flex max-w-[min(760px,calc(100vw-520px))] items-center gap-2 rounded-[8px] bg-white/86 px-3 py-2 text-[11px] font-semibold text-[#5a6062] shadow-sm ring-1 ring-black/[0.05] backdrop-blur-xl"
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight, Pause, Play, RotateCcw } from 'lucide-react'
|
||||
import type { InstallationWorkbenchMode } from '../types'
|
||||
|
||||
interface NumberingPreviewToolbarProps {
|
||||
mode: InstallationWorkbenchMode
|
||||
playing: boolean
|
||||
canPreview: boolean
|
||||
currentIndex: number
|
||||
totalCount: number
|
||||
showModeToggle?: boolean
|
||||
onModeChange: (mode: InstallationWorkbenchMode) => void
|
||||
onTogglePlaying: () => void
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
@ -14,10 +18,13 @@ interface NumberingPreviewToolbarProps {
|
||||
}
|
||||
|
||||
export function NumberingPreviewToolbar({
|
||||
mode,
|
||||
playing,
|
||||
canPreview,
|
||||
currentIndex,
|
||||
totalCount,
|
||||
showModeToggle = true,
|
||||
onModeChange,
|
||||
onTogglePlaying,
|
||||
onPrevious,
|
||||
onNext,
|
||||
@ -27,6 +34,22 @@ export function NumberingPreviewToolbar({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-[8px] border border-black/[0.06] bg-white/88 p-1 shadow-sm backdrop-blur-xl">
|
||||
{showModeToggle
|
||||
? (['edit', 'preview'] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => onModeChange(item)}
|
||||
className={[
|
||||
'h-8 rounded-[6px] px-3 text-[11px] font-bold transition',
|
||||
mode === item ? 'bg-[#2d3335] text-white' : 'text-[#5a6062] hover:bg-black/[0.04]',
|
||||
].join(' ')}
|
||||
>
|
||||
{item === 'edit' ? '编辑' : '预览'}
|
||||
</button>
|
||||
))
|
||||
: null}
|
||||
{showModeToggle ? <span className="mx-1 h-5 w-px bg-black/[0.08]" /> : null}
|
||||
<div className="min-w-[52px] px-2 text-center text-[11px] font-black text-[#2d3335]">{counter}</div>
|
||||
<span className="h-5 w-px bg-black/[0.08]" />
|
||||
<button
|
||||
|
||||
@ -114,6 +114,63 @@ function clusterHardwareProjections(projections: NumberingNodeScreenProjection[]
|
||||
return clusters
|
||||
}
|
||||
|
||||
function isCenteredHardwareProjection(
|
||||
projection: NumberingNodeScreenProjection,
|
||||
modelBounds: NumberingModelScreenBounds,
|
||||
) {
|
||||
const modelWidth = Math.max(1, modelBounds.maxX - modelBounds.minX)
|
||||
const modelHeight = Math.max(1, modelBounds.maxY - modelBounds.minY)
|
||||
const horizontalInset = modelWidth * 0.22
|
||||
const verticalInset = modelHeight * 0.16
|
||||
const safeMinX = modelBounds.minX + horizontalInset
|
||||
const safeMaxX = modelBounds.maxX - horizontalInset
|
||||
const safeMinY = modelBounds.minY + verticalInset
|
||||
const safeMaxY = modelBounds.maxY - verticalInset
|
||||
|
||||
return (
|
||||
projection.bounds.minX >= safeMinX &&
|
||||
projection.bounds.maxX <= safeMaxX &&
|
||||
projection.bounds.minY >= safeMinY &&
|
||||
projection.bounds.maxY <= safeMaxY
|
||||
)
|
||||
}
|
||||
|
||||
function buildCenteredHardwareDrafts(params: {
|
||||
partTarget: ResolvedInstallationNumberingPartTarget
|
||||
projections: NumberingNodeScreenProjection[]
|
||||
modelCenterX: number
|
||||
}) {
|
||||
return clusterHardwareProjections(params.projections).map(
|
||||
(projections, clusterIndex): NumberingMarkerDraft => {
|
||||
const boundsList = projections.map((projection) => projection.bounds)
|
||||
const centerX =
|
||||
(Math.min(...boundsList.map((bounds) => bounds.minX)) +
|
||||
Math.max(...boundsList.map((bounds) => bounds.maxX))) *
|
||||
0.5
|
||||
const centerY =
|
||||
(Math.min(...boundsList.map((bounds) => bounds.minY)) +
|
||||
Math.max(...boundsList.map((bounds) => bounds.maxY))) *
|
||||
0.5
|
||||
|
||||
return {
|
||||
markerId: `${params.partTarget.part.id}:center:${clusterIndex}`,
|
||||
partId: params.partTarget.part.id,
|
||||
code: params.partTarget.part.code,
|
||||
category: getMarkerCategory(params.partTarget.part),
|
||||
displayMode: 'center',
|
||||
side: centerX <= params.modelCenterX ? 'left' : 'right',
|
||||
region: 'left',
|
||||
anchors: [],
|
||||
averageX: centerX,
|
||||
centerX,
|
||||
centerY,
|
||||
sortY: centerY,
|
||||
labelY: centerY,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function classifyMarkerRegion(
|
||||
side: 'left' | 'right',
|
||||
anchors: NumberingAnchorDraft[],
|
||||
@ -203,10 +260,19 @@ function buildHardwareDrafts(params: {
|
||||
)
|
||||
if (visibleNodeProjections.length === 0) return []
|
||||
|
||||
const leftProjections = visibleNodeProjections.filter(
|
||||
const centeredProjections =
|
||||
params.partTarget.part.overlayAnchorMode === 'center'
|
||||
? visibleNodeProjections
|
||||
: visibleNodeProjections.filter((projection) =>
|
||||
isCenteredHardwareProjection(projection, params.resolvedModelBounds),
|
||||
)
|
||||
const calloutProjections = visibleNodeProjections.filter(
|
||||
(projection) => !centeredProjections.includes(projection),
|
||||
)
|
||||
const leftProjections = calloutProjections.filter(
|
||||
(projection) => projection.bounds.centerX <= params.modelCenterX,
|
||||
)
|
||||
const rightProjections = visibleNodeProjections.filter(
|
||||
const rightProjections = calloutProjections.filter(
|
||||
(projection) => projection.bounds.centerX > params.modelCenterX,
|
||||
)
|
||||
|
||||
@ -278,6 +344,11 @@ function buildHardwareDrafts(params: {
|
||||
}
|
||||
|
||||
return [
|
||||
...buildCenteredHardwareDrafts({
|
||||
partTarget: params.partTarget,
|
||||
projections: centeredProjections,
|
||||
modelCenterX: params.modelCenterX,
|
||||
}),
|
||||
...clusterHardwareProjections(leftProjections).map((cluster, index) =>
|
||||
buildDraft('left', cluster, index),
|
||||
),
|
||||
|
||||
@ -49,6 +49,7 @@ export function ViewportStageShell({
|
||||
showSceneGrid,
|
||||
onToggleSceneGrid,
|
||||
viewportControlsEnabled = true,
|
||||
showCameraActionButtons = true,
|
||||
enableTranslateGizmo = true,
|
||||
gizmoMode = 'translate',
|
||||
onGizmoModeChange,
|
||||
@ -70,7 +71,12 @@ export function ViewportStageShell({
|
||||
<div className="absolute inset-0 z-0">{children}</div>
|
||||
|
||||
{/* White Obsidian Atelier vertical viewport tools */}
|
||||
<div className="absolute left-2 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1 rounded-[8px] bg-white/72 p-1.5 shadow-[0_18px_48px_rgba(45,51,53,0.14)] ring-1 ring-white/70 backdrop-blur-2xl transition-all duration-300 supports-[backdrop-filter]:bg-white/62">
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-2 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1 rounded-[8px] bg-white/72 p-1.5 shadow-[0_18px_48px_rgba(45,51,53,0.14)] ring-1 ring-white/70 backdrop-blur-2xl transition-all duration-300 supports-[backdrop-filter]:bg-white/62',
|
||||
!showCameraActionButtons && 'hidden',
|
||||
)}
|
||||
>
|
||||
{/* 交互模式 */}
|
||||
{enableTranslateGizmo && (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user