feat: refine installation demo numbering preview

This commit is contained in:
WuYongJie 2026-07-10 16:49:30 +08:00
parent 6c407f208b
commit a789124f10
7 changed files with 255 additions and 116 deletions

View File

@ -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"

View File

@ -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>
)
}

View File

@ -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
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-[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
: '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-10 items-center bg-[#1f2526] px-4 text-[16px] font-black tracking-[0.08em] text-white'
const gridClassName = compact
: '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(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
: '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-[150px] border-b border-r border-[#2d3335]/20 bg-[#fbfbfa]'
const imageWrapClassName = compact
: '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-[112px] items-center justify-center px-8 pt-5'
: '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>

View File

@ -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,41 +323,17 @@ 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}
snapshotUrls={numberingSnapshotUrls}
visiblePartIds={numberingVisiblePartIds}
snapshotUrl={activeNumberingSnapshotUrl}
activePartId={activeNumberingPartId}
onSelectPart={onSelectPart}
/>
</div>
) : null}
</div>
</div>
</>
) : showExplodedNumberingOverlay ? (
<InstallationNumberingOverlay
@ -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"

View File

@ -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

View File

@ -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),
),

View File

@ -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">