feat(installation-demo): refine authored playback guidance
This commit is contained in:
parent
117abf7df0
commit
b4e6bb865c
@ -64,6 +64,7 @@ import {
|
||||
resolveInstallationNumberingSnapshotModelIdentity,
|
||||
} from './lib/installationNumberingSnapshotModel'
|
||||
import {
|
||||
buildInstallationStepBoardAttentionParts,
|
||||
buildInstallationStepMaterialItems,
|
||||
buildInstallationStepMaterialMarkerParts,
|
||||
} from './lib/installationStepMaterials'
|
||||
@ -473,6 +474,16 @@ export default function InstallationDemoWorkbenchClient({
|
||||
() => buildInstallationStepMaterialMarkerParts(stepMaterialItems),
|
||||
[stepMaterialItems],
|
||||
)
|
||||
const stepBoardAttentionParts = useMemo(
|
||||
() =>
|
||||
buildInstallationStepBoardAttentionParts({
|
||||
step: preview.activeStep,
|
||||
steps: stepEditor.steps,
|
||||
stepParts: partCatalog.parts,
|
||||
numberingParts: numberingCatalog.parts,
|
||||
}),
|
||||
[numberingCatalog.parts, partCatalog.parts, preview.activeStep, stepEditor.steps],
|
||||
)
|
||||
const previewStepMaterialMarkerParts = (() => {
|
||||
if (
|
||||
preview.stageView !== 'preview' ||
|
||||
@ -1620,6 +1631,7 @@ export default function InstallationDemoWorkbenchClient({
|
||||
stepMaterialItems={stepMaterialItems}
|
||||
stepMaterialThumbnailUrls={resolvedStepMaterialThumbnailUrls}
|
||||
stepMaterialMarkerParts={previewStepMaterialMarkerParts}
|
||||
boardAttentionParts={stepBoardAttentionParts}
|
||||
playbackActionId={preview.localPlaybackActionId}
|
||||
playbackActionNodeNames={preview.localPlaybackActionNodeNames}
|
||||
playbackActionProgress={preview.localPlaybackActionProgress}
|
||||
|
||||
@ -7,6 +7,8 @@ interface InstallationNumberingMarkerLayerProps {
|
||||
markers: NumberingMarkerLayout[]
|
||||
selectedPartId: string | null
|
||||
onSelectPart: (partId: string) => void
|
||||
tone?: 'part' | 'hardware' | 'tool'
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
interface MarkerLayerSize {
|
||||
@ -17,6 +19,20 @@ interface MarkerLayerSize {
|
||||
const COMPACT_VISIBLE_ANCHOR_PADDING = 28
|
||||
const COMPACT_VISIBLE_LABEL_PADDING = 30
|
||||
const COMPACT_MAX_LEADER_DISTANCE = 150
|
||||
const MARKER_TONE_CLASSES = {
|
||||
part: {
|
||||
active: 'border-[#4f7390] bg-[#4f7390] text-white',
|
||||
idle: 'border-[#89aabd]/80 bg-[#edf5f8]/95 text-[#36576c] hover:border-[#4f7390]',
|
||||
},
|
||||
hardware: {
|
||||
active: 'border-[#b66735] bg-[#b66735] text-white',
|
||||
idle: 'border-[#e0a06b]/80 bg-[#fff3e8]/95 text-[#8b4820] hover:border-[#b66735]',
|
||||
},
|
||||
tool: {
|
||||
active: 'border-[#3c8b7f] bg-[#3c8b7f] text-white',
|
||||
idle: 'border-[#72b7a9]/80 bg-[#eaf7f3]/95 text-[#28685f] hover:border-[#3c8b7f]',
|
||||
},
|
||||
} as const
|
||||
|
||||
function isCompactMarker(marker: NumberingMarkerLayout) {
|
||||
return marker.boxHeight < 20
|
||||
@ -117,6 +133,8 @@ export function InstallationNumberingMarkerLayer({
|
||||
markers,
|
||||
selectedPartId,
|
||||
onSelectPart,
|
||||
tone,
|
||||
interactive = true,
|
||||
}: InstallationNumberingMarkerLayerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const [layerSize, setLayerSize] = useState<MarkerLayerSize>({ width: 0, height: 0 })
|
||||
@ -192,28 +210,46 @@ export function InstallationNumberingMarkerLayer({
|
||||
|
||||
{markers.filter((marker) => shouldRenderMarker(marker, layerSize)).map((marker) => {
|
||||
const selected = marker.partId === selectedPartId
|
||||
const toneClasses = tone ? MARKER_TONE_CLASSES[tone] : null
|
||||
const markerClassName = [
|
||||
'absolute flex items-center justify-center rounded-[7px] border text-[13px] font-black shadow-sm transition outline-none focus:outline-none focus-visible:ring-1 focus-visible:ring-[#2d3335]/20',
|
||||
toneClasses
|
||||
? selected
|
||||
? toneClasses.active
|
||||
: toneClasses.idle
|
||||
: selected
|
||||
? 'border-[#2d3335] bg-[#2d3335] text-white'
|
||||
: 'border-black/10 bg-white/94 text-[#2d3335] backdrop-blur-sm hover:border-[#2d3335]/30',
|
||||
].join(' ')
|
||||
const markerStyle = {
|
||||
left: marker.boxX,
|
||||
top: marker.boxY,
|
||||
width: marker.boxWidth,
|
||||
height: marker.boxHeight,
|
||||
minWidth: marker.boxWidth,
|
||||
minHeight: marker.boxHeight,
|
||||
padding: 0,
|
||||
borderRadius: Math.max(4, marker.boxHeight * 0.25),
|
||||
fontSize: Math.max(8.5, Math.min(13, marker.boxHeight * 0.46)),
|
||||
}
|
||||
if (!interactive) {
|
||||
return (
|
||||
<span
|
||||
key={marker.markerId}
|
||||
className={`pointer-events-none ${markerClassName}`}
|
||||
style={markerStyle}
|
||||
>
|
||||
{marker.code}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={marker.markerId}
|
||||
type="button"
|
||||
onClick={() => onSelectPart(marker.partId)}
|
||||
className={[
|
||||
'pointer-events-auto absolute flex items-center justify-center rounded-[7px] border text-[13px] font-black shadow-sm transition outline-none focus:outline-none focus-visible:ring-1 focus-visible:ring-[#2d3335]/20',
|
||||
selected
|
||||
? 'border-[#2d3335] bg-[#2d3335] text-white'
|
||||
: 'border-black/10 bg-white/94 text-[#2d3335] backdrop-blur-sm hover:border-[#2d3335]/30',
|
||||
].join(' ')}
|
||||
style={{
|
||||
left: marker.boxX,
|
||||
top: marker.boxY,
|
||||
width: marker.boxWidth,
|
||||
height: marker.boxHeight,
|
||||
minWidth: marker.boxWidth,
|
||||
minHeight: marker.boxHeight,
|
||||
padding: 0,
|
||||
borderRadius: Math.max(4, marker.boxHeight * 0.25),
|
||||
fontSize: Math.max(8.5, Math.min(13, marker.boxHeight * 0.46)),
|
||||
}}
|
||||
className={`pointer-events-auto ${markerClassName}`}
|
||||
style={markerStyle}
|
||||
>
|
||||
{marker.code}
|
||||
</button>
|
||||
|
||||
@ -28,6 +28,9 @@ interface InstallationNumberingOverlayProps {
|
||||
onSelectPart: (partId: string) => void
|
||||
layoutVariant?: NumberingOverlayLayoutVariant
|
||||
safeInsets?: Partial<NumberingOverlaySafeInsets>
|
||||
tone?: 'part' | 'hardware' | 'tool'
|
||||
interactive?: boolean
|
||||
followSceneTransforms?: boolean
|
||||
}
|
||||
|
||||
export function InstallationNumberingOverlay({
|
||||
@ -38,6 +41,9 @@ export function InstallationNumberingOverlay({
|
||||
onSelectPart,
|
||||
layoutVariant = 'numbering',
|
||||
safeInsets,
|
||||
tone,
|
||||
interactive = true,
|
||||
followSceneTransforms = false,
|
||||
}: InstallationNumberingOverlayProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
@ -159,9 +165,13 @@ export function InstallationNumberingOverlay({
|
||||
|
||||
const now = performance.now()
|
||||
const nextLayoutStateKey = buildInstallationNumberingLayoutStateKey(engine, width, height)
|
||||
const shouldRefreshLayout =
|
||||
followSceneTransforms || nextLayoutStateKey !== lastLayoutStateKey
|
||||
if (
|
||||
nextLayoutStateKey === lastLayoutStateKey ||
|
||||
(lastLayoutStateKey && now - lastLayoutAt < LAYOUT_RECALCULATE_INTERVAL_MS)
|
||||
!shouldRefreshLayout ||
|
||||
(!followSceneTransforms &&
|
||||
lastLayoutStateKey &&
|
||||
now - lastLayoutAt < LAYOUT_RECALCULATE_INTERVAL_MS)
|
||||
) {
|
||||
frameRef.current = window.requestAnimationFrame(render)
|
||||
return
|
||||
@ -194,7 +204,14 @@ export function InstallationNumberingOverlay({
|
||||
frameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [engine, layoutVariant, resolvedPartTargets, safeInsetKey, safeInsets])
|
||||
}, [
|
||||
engine,
|
||||
followSceneTransforms,
|
||||
layoutVariant,
|
||||
resolvedPartTargets,
|
||||
safeInsetKey,
|
||||
safeInsets,
|
||||
])
|
||||
|
||||
if (!engine || resolvedPartTargets.length === 0) {
|
||||
return null
|
||||
@ -203,11 +220,13 @@ export function InstallationNumberingOverlay({
|
||||
return (
|
||||
<div ref={containerRef} className="pointer-events-none absolute inset-0 z-10">
|
||||
{!isCameraInteracting ? (
|
||||
<InstallationNumberingMarkerLayer
|
||||
markers={markers}
|
||||
selectedPartId={selectedPartId}
|
||||
onSelectPart={onSelectPart}
|
||||
/>
|
||||
<InstallationNumberingMarkerLayer
|
||||
markers={markers}
|
||||
selectedPartId={selectedPartId}
|
||||
onSelectPart={onSelectPart}
|
||||
tone={tone}
|
||||
interactive={interactive}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -35,6 +35,10 @@ import { SelectionModeToolbar } from './SelectionModeToolbar'
|
||||
import { StepPreviewToolbar } from './StepPreviewToolbar'
|
||||
import type { InstallationStepMaterialItem } from '../lib/installationStepMaterials'
|
||||
import { useInstallationToolAttachments } from '../hooks/useInstallationToolAttachments'
|
||||
import {
|
||||
useInstallationRuntimeCategoryOutlines,
|
||||
type InstallationRuntimeOutlineTarget,
|
||||
} from '../hooks/useInstallationRuntimeCategoryOutlines'
|
||||
import {
|
||||
collectInstallationToolInstanceNodeNames,
|
||||
isInstallationToolInstanceNodeName,
|
||||
@ -101,6 +105,7 @@ interface InstallationPreviewStageProps {
|
||||
stepMaterialItems: InstallationStepMaterialItem[]
|
||||
stepMaterialThumbnailUrls: Record<string, string>
|
||||
stepMaterialMarkerParts: InstallationPartItem[]
|
||||
boardAttentionParts?: InstallationPartItem[]
|
||||
playbackActionId?: string | null
|
||||
playbackActionNodeNames?: string[]
|
||||
playbackActionProgress?: number
|
||||
@ -193,6 +198,7 @@ export function InstallationPreviewStage({
|
||||
numberingToolSnapshotUrls = {},
|
||||
stepMaterialItems,
|
||||
stepMaterialThumbnailUrls,
|
||||
boardAttentionParts = [],
|
||||
playbackActionId = null,
|
||||
playbackActionNodeNames = [],
|
||||
playbackActionProgress = 0,
|
||||
@ -283,6 +289,41 @@ export function InstallationPreviewStage({
|
||||
const toolAttachmentToolIds = useMemo(() => drawingTools.map((tool) => tool.id), [drawingTools])
|
||||
const fallbackToolId = drawingTools.length === 1 ? drawingTools[0]?.id : undefined
|
||||
const toolAttachmentErrorKey = `${toolAttachmentAction?.id ?? 'none'}:${toolAttachmentToolIds.join(':')}`
|
||||
const playbackCategoryOutlineTargets = useMemo<InstallationRuntimeOutlineTarget[]>(() => {
|
||||
if (!playbackAction || playbackShowingFixedGroup || playbackFinalizingAssembly) return []
|
||||
|
||||
const activeActionNodeNames = playbackAction.nodeNames
|
||||
const activeNodeNameSet = new Set(activeActionNodeNames)
|
||||
const targets = hardwareParts.flatMap((part) => {
|
||||
const nodeNames = getPartNodeNames(part).filter((nodeName) =>
|
||||
activeNodeNameSet.has(nodeName),
|
||||
)
|
||||
if (nodeNames.length === 0) return []
|
||||
return nodeNames.map((nodeName) => ({ nodeName, kind: part.kind }))
|
||||
})
|
||||
const removedToolNodeNameSet = new Set(playbackAction.removedToolNodeNames ?? [])
|
||||
const toolTargets = collectInstallationToolInstanceNodeNames(
|
||||
hardwareParts,
|
||||
activeActionNodeNames,
|
||||
Object.keys(playbackAction.toolTransforms ?? {}),
|
||||
{ availableToolIds: toolAttachmentToolIds, fallbackToolId },
|
||||
)
|
||||
.filter((nodeName) => !removedToolNodeNameSet.has(nodeName))
|
||||
.map((nodeName) => ({ nodeName, kind: 'tool' as const }))
|
||||
|
||||
return Array.from(
|
||||
new Map(
|
||||
[...targets, ...toolTargets].map((target) => [target.nodeName, target] as const),
|
||||
).values(),
|
||||
)
|
||||
}, [
|
||||
fallbackToolId,
|
||||
hardwareParts,
|
||||
playbackAction,
|
||||
playbackFinalizingAssembly,
|
||||
playbackShowingFixedGroup,
|
||||
toolAttachmentToolIds,
|
||||
])
|
||||
const editingToolNodeNames = useMemo(
|
||||
() => {
|
||||
if (!editingAction) return []
|
||||
@ -359,6 +400,11 @@ export function InstallationPreviewStage({
|
||||
sceneKey: `${modelUrl ?? 'no-model'}:${modelNodeNames.join('\u001f')}`,
|
||||
onAssetLoadError: handleToolAssetLoadError,
|
||||
})
|
||||
useInstallationRuntimeCategoryOutlines({
|
||||
engine: previewEngine,
|
||||
enabled: !isToolPreview && view === 'preview' && playbackCategoryOutlineTargets.length > 0,
|
||||
targets: playbackCategoryOutlineTargets,
|
||||
})
|
||||
const handleStageNodeSelect = useCallback<PreviewNodeSelectHandler>(
|
||||
(payload) => {
|
||||
setToolContextMenu(null)
|
||||
@ -422,6 +468,20 @@ export function InstallationPreviewStage({
|
||||
view,
|
||||
])
|
||||
const showStepMaterials = activeStepMaterialItems.length > 0
|
||||
const activeBoardAttentionParts = useMemo(() => {
|
||||
if (!activeStep) return []
|
||||
const activeNodeNames = playbackShowingFixedGroup
|
||||
? activeStep.fixedNodeNames
|
||||
: playbackActionNodeNames
|
||||
if (activeNodeNames.length === 0) return []
|
||||
|
||||
const activeNodeNameSet = new Set(activeNodeNames)
|
||||
return boardAttentionParts.filter((part) =>
|
||||
getPartNodeNames(part).some((nodeName) => activeNodeNameSet.has(nodeName)),
|
||||
)
|
||||
}, [activeStep, boardAttentionParts, playbackActionNodeNames, playbackShowingFixedGroup])
|
||||
const showBoardAttentionOverlay =
|
||||
!isToolPreview && view === 'preview' && activeBoardAttentionParts.length > 0
|
||||
const showExplodedNumberingOverlay = !isToolPreview && view === 'exploded' && explodedNumberingParts.length > 0
|
||||
const showNumberingPresentation =
|
||||
!isToolPreview && view === 'numbering' && numberingParts.length > 0 && (!canEdit || mode === 'preview')
|
||||
@ -623,6 +683,17 @@ export function InstallationPreviewStage({
|
||||
onSelectPart={onSelectPart}
|
||||
layoutVariant="numbering"
|
||||
/>
|
||||
) : showBoardAttentionOverlay ? (
|
||||
<InstallationNumberingOverlay
|
||||
engine={previewEngine}
|
||||
parts={activeBoardAttentionParts}
|
||||
selectedPartId={null}
|
||||
onSelectPart={() => {}}
|
||||
layoutVariant="materials"
|
||||
tone="part"
|
||||
interactive={false}
|
||||
followSceneTransforms={localTransformPlaybackActive}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
overlayRight={
|
||||
|
||||
@ -62,9 +62,14 @@ export function InstallationStepMaterialCard({
|
||||
{formatQuantity(item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[9px] font-bold text-[#8b9092]">
|
||||
{item.name || formatKindLabel(item.kind)}
|
||||
</p>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1">
|
||||
<span className="shrink-0 rounded-[3px] bg-[#eceff0] px-1 py-0.5 text-[8px] font-black text-[#697174]">
|
||||
{formatKindLabel(item.kind)}
|
||||
</span>
|
||||
<p className="truncate text-[9px] font-bold text-[#8b9092]">
|
||||
{item.name || formatKindLabel(item.kind)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -461,13 +461,17 @@ export function useInstallationPreviewController({
|
||||
() => resolveInstallationViewAnimationDurationMs(stageView, activeStep),
|
||||
[activeStep, stageView],
|
||||
)
|
||||
// Keep a finish-close group expanded while authoring its step. The editor
|
||||
// still lets the currently selected action override that temporary pose.
|
||||
const localTransformActiveStep = playbackActiveStep
|
||||
const localTransformNextStep = stageView === 'preview' ? playbackNextStep : null
|
||||
const localTransformFrame = useInstallationLocalTransformPreview({
|
||||
activeStep:
|
||||
stepWorkspaceMode === 'scope' ||
|
||||
!isInstallationStepLocalTransformWorkflow(playbackActiveStep)
|
||||
!isInstallationStepLocalTransformWorkflow(localTransformActiveStep)
|
||||
? null
|
||||
: playbackActiveStep,
|
||||
nextStep: playbackNextStep,
|
||||
: localTransformActiveStep,
|
||||
nextStep: localTransformNextStep,
|
||||
engine,
|
||||
stageView,
|
||||
animationDurationMs,
|
||||
|
||||
@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import type { ThreeDEngine } from '@/lib/3d/engine'
|
||||
|
||||
export type InstallationRuntimeOutlineKind = 'part' | 'hardware' | 'tool'
|
||||
|
||||
export interface InstallationRuntimeOutlineTarget {
|
||||
nodeName: string
|
||||
kind: InstallationRuntimeOutlineKind
|
||||
}
|
||||
|
||||
interface InstallationRuntimeCategoryOutlineOptions {
|
||||
engine: ThreeDEngine | null
|
||||
enabled: boolean
|
||||
targets: InstallationRuntimeOutlineTarget[]
|
||||
}
|
||||
|
||||
const OUTLINE_NODE_NAME = '__installation_category_outline__'
|
||||
const TOOL_RETRY_INTERVAL_MS = 160
|
||||
const OUTLINE_STYLE: Record<InstallationRuntimeOutlineKind, number> = {
|
||||
part: 0x6f9fb8,
|
||||
hardware: 0xe28f4f,
|
||||
tool: 0x25b99e,
|
||||
}
|
||||
|
||||
function removeOutline(outline: THREE.LineSegments) {
|
||||
outline.removeFromParent()
|
||||
outline.geometry.dispose()
|
||||
if (Array.isArray(outline.material)) {
|
||||
outline.material.forEach((material) => material.dispose())
|
||||
return
|
||||
}
|
||||
outline.material.dispose()
|
||||
}
|
||||
|
||||
function createOutline(mesh: THREE.Mesh, kind: InstallationRuntimeOutlineKind) {
|
||||
const geometry = new THREE.EdgesGeometry(mesh.geometry, 18)
|
||||
if (!geometry.attributes.position || geometry.attributes.position.count === 0) {
|
||||
geometry.dispose()
|
||||
return null
|
||||
}
|
||||
|
||||
const outline = new THREE.LineSegments(
|
||||
geometry,
|
||||
new THREE.LineBasicMaterial({
|
||||
color: OUTLINE_STYLE[kind],
|
||||
transparent: true,
|
||||
opacity: 0.96,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
}),
|
||||
)
|
||||
outline.name = OUTLINE_NODE_NAME
|
||||
outline.renderOrder = 2800
|
||||
outline.frustumCulled = false
|
||||
outline.raycast = () => {}
|
||||
return outline
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds disposable category-colored outlines for the current installation action.
|
||||
* It leaves mesh materials unchanged and removes every outline when the action ends.
|
||||
*/
|
||||
export function useInstallationRuntimeCategoryOutlines({
|
||||
engine,
|
||||
enabled,
|
||||
targets,
|
||||
}: InstallationRuntimeCategoryOutlineOptions) {
|
||||
useEffect(() => {
|
||||
if (!engine || !enabled || targets.length === 0) return
|
||||
|
||||
const targetsByNodeName = new Map(
|
||||
targets
|
||||
.map((target) => [target.nodeName.trim(), target.kind] as const)
|
||||
.filter(([nodeName]) => Boolean(nodeName)),
|
||||
)
|
||||
if (targetsByNodeName.size === 0) return
|
||||
|
||||
const outlines: THREE.LineSegments[] = []
|
||||
const outlinedMeshIds = new Set<string>()
|
||||
let disposed = false
|
||||
|
||||
const attachOutlines = () => {
|
||||
if (disposed) return
|
||||
|
||||
targetsByNodeName.forEach((kind, nodeName) => {
|
||||
const target =
|
||||
engine.getScene().getObjectByName(nodeName) ??
|
||||
engine.resolveObjectByNodeName(nodeName)
|
||||
if (!target) return
|
||||
|
||||
target.traverse((child) => {
|
||||
if (!(child as THREE.Mesh).isMesh || child.name === OUTLINE_NODE_NAME) return
|
||||
const mesh = child as THREE.Mesh
|
||||
if (outlinedMeshIds.has(mesh.uuid)) return
|
||||
const outline = createOutline(mesh, kind)
|
||||
if (!outline) return
|
||||
mesh.add(outline)
|
||||
outlines.push(outline)
|
||||
outlinedMeshIds.add(mesh.uuid)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
attachOutlines()
|
||||
const retryTimer = window.setInterval(attachOutlines, TOOL_RETRY_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
window.clearInterval(retryTimer)
|
||||
outlines.forEach(removeOutline)
|
||||
}
|
||||
}, [enabled, engine, targets])
|
||||
}
|
||||
@ -1,10 +1,6 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 安装演示工作台统一配置存储(PostgreSQL)
|
||||
* 所有浏览器读写同一份数据,按 drawingId 分组。
|
||||
* 替代原来的 localStorage 方案。
|
||||
*/
|
||||
import { isInstallationDemoLocalRuntime } from './installationDemoRuntimeStorage'
|
||||
|
||||
export interface InstallationDemoConfigPayload {
|
||||
parts?: unknown[] | Record<string, unknown[]>
|
||||
@ -31,13 +27,14 @@ interface ServerConfigRow {
|
||||
explodeTemplate: unknown | null
|
||||
}
|
||||
|
||||
const CACHE_PREFIX = 'installation-demo-workbench:config-cache:v1'
|
||||
const STEP_RECOVERY_PREFIX = 'installation-demo-workbench:step-recovery:v1'
|
||||
|
||||
interface InstallationStepRecoveryDraft {
|
||||
steps: InstallationDemoConfigPayload['steps']
|
||||
}
|
||||
|
||||
const LEGACY_CACHE_PREFIX = 'installation-demo-workbench:config-cache:v1'
|
||||
const LOCAL_CONFIG_PREFIX = 'installation-demo-workbench:local-config:v2'
|
||||
const STEP_RECOVERY_PREFIX = 'installation-demo-workbench:step-recovery:v1'
|
||||
|
||||
function isCurrentInstallationStepWorkflow(steps: InstallationDemoConfigPayload['steps']) {
|
||||
const stepList = Array.isArray(steps) ? steps : []
|
||||
return stepList.some((step) => {
|
||||
@ -47,112 +44,160 @@ function isCurrentInstallationStepWorkflow(steps: InstallationDemoConfigPayload[
|
||||
})
|
||||
}
|
||||
|
||||
function readStepRecoveryDraft(drawingId: string): InstallationStepRecoveryDraft | null {
|
||||
function readRecoveryDraft(drawingId: string, { legacy = false }: { legacy?: boolean } = {}) {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (!legacy && !isInstallationDemoLocalRuntime()) return null
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(`${STEP_RECOVERY_PREFIX}:${drawingId}`)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as InstallationStepRecoveryDraft
|
||||
if (!parsed || !('steps' in parsed)) return null
|
||||
if (isCurrentInstallationStepWorkflow(parsed.steps)) return parsed
|
||||
|
||||
// Old generic step forms must never replace the current action workflow.
|
||||
window.localStorage.removeItem(`${STEP_RECOVERY_PREFIX}:${drawingId}`)
|
||||
return null
|
||||
if (!parsed || !('steps' in parsed) || !isCurrentInstallationStepWorkflow(parsed.steps)) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeStepRecoveryDraft(drawingId: string, steps: InstallationDemoConfigPayload['steps']) {
|
||||
if (typeof window === 'undefined') return
|
||||
function writeRecoveryDraft(drawingId: string, steps: InstallationDemoConfigPayload['steps']) {
|
||||
if (!isInstallationDemoLocalRuntime()) return
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${STEP_RECOVERY_PREFIX}:${drawingId}`,
|
||||
JSON.stringify({ steps } satisfies InstallationStepRecoveryDraft),
|
||||
)
|
||||
} catch {
|
||||
// Keep the in-session cache as a fallback when persistent browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function clearStepRecoveryDraft(drawingId: string, savedSteps: InstallationDemoConfigPayload['steps']) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const current = readStepRecoveryDraft(drawingId)
|
||||
if (JSON.stringify(current?.steps) !== JSON.stringify(savedSteps)) return
|
||||
window.localStorage.removeItem(`${STEP_RECOVERY_PREFIX}:${drawingId}`)
|
||||
} catch {
|
||||
// A stale recovery draft is safer than deleting a newer local edit.
|
||||
// The current page state remains available even when browser storage is full.
|
||||
}
|
||||
}
|
||||
|
||||
export function hasInstallationDemoStepRecovery(drawingId: string) {
|
||||
return readStepRecoveryDraft(drawingId) !== null
|
||||
return readRecoveryDraft(drawingId) !== null
|
||||
}
|
||||
|
||||
function readCache(drawingId: string): InstallationDemoConfigPayload | null {
|
||||
function readLocalConfig(drawingId: string): InstallationDemoConfigPayload | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(`${CACHE_PREFIX}:${drawingId}`)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw) as InstallationDemoConfigPayload
|
||||
const raw = window.localStorage.getItem(`${LOCAL_CONFIG_PREFIX}:${drawingId}`)
|
||||
return raw ? (JSON.parse(raw) as InstallationDemoConfigPayload) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(drawingId: string, payload: InstallationDemoConfigPayload) {
|
||||
function writeLocalConfig(drawingId: string, payload: InstallationDemoConfigPayload) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
// 合并缓存,不覆盖其他字段
|
||||
const existing = readCache(drawingId) ?? {}
|
||||
const merged = { ...existing, ...payload }
|
||||
window.sessionStorage.setItem(`${CACHE_PREFIX}:${drawingId}`, JSON.stringify(merged))
|
||||
window.localStorage.setItem(
|
||||
`${LOCAL_CONFIG_PREFIX}:${drawingId}`,
|
||||
JSON.stringify({ ...(readLocalConfig(drawingId) ?? {}), ...payload }),
|
||||
)
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
// Browser storage failure must not discard the live editor state.
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchInstallationDemoConfig(drawingId: string): Promise<InstallationDemoConfigPayload | null> {
|
||||
const recovery = readStepRecoveryDraft(drawingId)
|
||||
function readLegacyConfig(drawingId: string): InstallationDemoConfigPayload | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/installation-demo-workbench/config?drawingId=${encodeURIComponent(drawingId)}`, {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!resp.ok) return recovery ? { ...(readCache(drawingId) ?? {}), steps: recovery.steps } : readCache(drawingId)
|
||||
const payload = (await resp.json()) as { success?: boolean; data?: ServerConfigRow | null }
|
||||
if (!payload.success || !payload.data) {
|
||||
return recovery ? { ...(readCache(drawingId) ?? {}), steps: recovery.steps } : readCache(drawingId)
|
||||
const raw = window.sessionStorage.getItem(`${LEGACY_CACHE_PREFIX}:${drawingId}`)
|
||||
const cache = raw ? (JSON.parse(raw) as InstallationDemoConfigPayload) : {}
|
||||
const recovery = readRecoveryDraft(drawingId, { legacy: true })
|
||||
const candidate = recovery ? { ...cache, steps: recovery.steps } : cache
|
||||
return Object.keys(candidate).length > 0 ? candidate : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function toPayload(row: ServerConfigRow): InstallationDemoConfigPayload {
|
||||
return {
|
||||
parts: (row.parts as InstallationDemoConfigPayload['parts']) ?? [],
|
||||
steps: (row.steps as InstallationDemoConfigPayload['steps']) ?? [],
|
||||
numbering: (row.numbering as Record<string, unknown>) ?? null,
|
||||
toolIds: Array.isArray(row.toolIds) ? row.toolIds : [],
|
||||
uploadedModel: (row.uploadedModel as Record<string, unknown>) ?? null,
|
||||
explodeTemplate: (row.explodeTemplate as Record<string, unknown>) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSharedConfig(drawingId: string) {
|
||||
const response = await fetch(`/api/installation-demo-workbench/config?drawingId=${encodeURIComponent(drawingId)}`, {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`安装演示配置读取失败 (${response.status})`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { success?: boolean; data?: ServerConfigRow | null; error?: { message?: string } }
|
||||
if (!payload.success) {
|
||||
throw new Error(payload.error?.message || '安装演示配置读取失败')
|
||||
}
|
||||
return payload.data ? toPayload(payload.data) : null
|
||||
}
|
||||
|
||||
async function saveSharedConfig(drawingId: string, payload: InstallationDemoConfigPayload) {
|
||||
const response = await fetch('/api/installation-demo-workbench/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ drawingId, ...payload }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const result = (await response.json().catch(() => null)) as { error?: { message?: string } } | null
|
||||
throw new Error(result?.error?.message || '安装演示配置保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchInstallationDemoConfig(
|
||||
drawingId: string,
|
||||
): Promise<InstallationDemoConfigPayload | null> {
|
||||
if (isInstallationDemoLocalRuntime()) {
|
||||
const local = readLocalConfig(drawingId)
|
||||
if (local) return local
|
||||
|
||||
const legacy = readLegacyConfig(drawingId)
|
||||
if (legacy) {
|
||||
writeLocalConfig(drawingId, legacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
const result: InstallationDemoConfigPayload = {
|
||||
parts: (payload.data.parts as InstallationDemoConfigPayload['parts']) ?? [],
|
||||
steps: (payload.data.steps as InstallationDemoConfigPayload['steps']) ?? [],
|
||||
numbering: (payload.data.numbering as Record<string, unknown>) ?? null,
|
||||
toolIds: Array.isArray(payload.data.toolIds) ? payload.data.toolIds : [],
|
||||
uploadedModel: (payload.data.uploadedModel as Record<string, unknown>) ?? null,
|
||||
explodeTemplate: (payload.data.explodeTemplate as Record<string, unknown>) ?? null,
|
||||
// Preserve existing local database records once, then keep later edits in
|
||||
// browser storage as requested for localhost:3001.
|
||||
try {
|
||||
const sharedLocalRecord = await fetchSharedConfig(drawingId)
|
||||
if (sharedLocalRecord) writeLocalConfig(drawingId, sharedLocalRecord)
|
||||
return sharedLocalRecord
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const resolved = recovery ? { ...result, steps: recovery.steps } : result
|
||||
writeCache(drawingId, resolved)
|
||||
return resolved
|
||||
} catch {
|
||||
return recovery ? { ...(readCache(drawingId) ?? {}), steps: recovery.steps } : readCache(drawingId)
|
||||
}
|
||||
|
||||
const shared = await fetchSharedConfig(drawingId)
|
||||
if (shared) return shared
|
||||
|
||||
// One-time migration for an old browser cache. It is used only when the
|
||||
// production record does not exist, so it cannot overwrite cloud data.
|
||||
const legacy = readLegacyConfig(drawingId)
|
||||
if (!legacy) return null
|
||||
await saveSharedConfig(drawingId, legacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
const pendingSaves = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const pendingPayloads = new Map<string, InstallationDemoConfigPayload>()
|
||||
|
||||
export function saveInstallationDemoConfig(drawingId: string, payload: InstallationDemoConfigPayload) {
|
||||
writeCache(drawingId, payload)
|
||||
if (payload.steps !== undefined) {
|
||||
writeStepRecoveryDraft(drawingId, payload.steps)
|
||||
if (isInstallationDemoLocalRuntime()) {
|
||||
writeLocalConfig(drawingId, payload)
|
||||
if (payload.steps !== undefined) writeRecoveryDraft(drawingId, payload.steps)
|
||||
return
|
||||
}
|
||||
|
||||
// 防抖:连续修改时延迟保存,避免频繁请求
|
||||
// 合并待保存的 payload(只传自己负责的字段,undefined 的不传,后端会保留原值)
|
||||
const existing = pendingPayloads.get(drawingId) ?? {}
|
||||
pendingPayloads.set(drawingId, { ...existing, ...payload })
|
||||
|
||||
@ -166,19 +211,25 @@ export function saveInstallationDemoConfig(drawingId: string, payload: Installat
|
||||
pendingPayloads.delete(drawingId)
|
||||
pendingSaves.delete(drawingId)
|
||||
try {
|
||||
const response = await fetch('/api/installation-demo-workbench/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ drawingId, ...merged }),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to save installation demo config')
|
||||
if (merged.steps !== undefined) {
|
||||
clearStepRecoveryDraft(drawingId, merged.steps)
|
||||
}
|
||||
} catch {
|
||||
// 网络错误忽略,数据在 sessionStorage 缓存里不会丢
|
||||
await saveSharedConfig(drawingId, merged)
|
||||
} catch (error) {
|
||||
// Production must not silently fall back to browser data. The live edit
|
||||
// remains visible and the failure stays available for diagnosis.
|
||||
console.error('安装演示配置未写入正式数据库', error)
|
||||
}
|
||||
}, 800),
|
||||
)
|
||||
}
|
||||
|
||||
export async function saveInstallationDemoConfigImmediately(
|
||||
drawingId: string,
|
||||
payload: InstallationDemoConfigPayload,
|
||||
) {
|
||||
if (isInstallationDemoLocalRuntime()) {
|
||||
writeLocalConfig(drawingId, payload)
|
||||
if (payload.steps !== undefined) writeRecoveryDraft(drawingId, payload.steps)
|
||||
return
|
||||
}
|
||||
|
||||
await saveSharedConfig(drawingId, payload)
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { InstallationPartItem, InstallationStepDraft } from '../types'
|
||||
import { getPartNodeNames } from './installationPartCatalogHelpers'
|
||||
import { isInstallationToolInstanceNodeName } from './installationToolAttachmentIdentity'
|
||||
|
||||
function uniqueNodeNames(nodeNames: Array<string | null | undefined>) {
|
||||
return Array.from(
|
||||
@ -24,27 +25,37 @@ export function collectInstallationFinishCloseNodeNames(parts: InstallationPartI
|
||||
)
|
||||
}
|
||||
|
||||
function collectHardwareNodeNames(parts: InstallationPartItem[]) {
|
||||
function collectToolNodeNames(parts: InstallationPartItem[]) {
|
||||
return new Set(
|
||||
parts
|
||||
.filter((part) => part.kind === 'hardware')
|
||||
.filter((part) => part.kind === 'tool')
|
||||
.flatMap((part) => getPartNodeNames(part)),
|
||||
)
|
||||
}
|
||||
|
||||
function collectFinishCloseCompanionHardwareNodeNames(params: {
|
||||
function collectFinishCloseActionGroupNodeNames(params: {
|
||||
step: InstallationStepDraft
|
||||
doorNodeNames: string[]
|
||||
hardwareNodeNameSet: Set<string>
|
||||
toolNodeNameSet: Set<string>
|
||||
}) {
|
||||
const doorNodeNameSet = new Set(params.doorNodeNames)
|
||||
if (doorNodeNameSet.size === 0) return []
|
||||
|
||||
const hasDoorInstallationAction = (params.step.actions ?? []).some((action) =>
|
||||
action.nodeNames.some((nodeName) => doorNodeNameSet.has(nodeName)),
|
||||
)
|
||||
if (!hasDoorInstallationAction) return []
|
||||
|
||||
const fixedNodeNameSet = new Set(params.step.fixedNodeNames)
|
||||
|
||||
return uniqueNodeNames(
|
||||
(params.step.actions ?? [])
|
||||
.filter((action) => action.nodeNames.some((nodeName) => doorNodeNameSet.has(nodeName)))
|
||||
.flatMap((action) =>
|
||||
action.nodeNames.filter((nodeName) => params.hardwareNodeNameSet.has(nodeName)),
|
||||
.flatMap((action) => action.nodeNames)
|
||||
.filter(
|
||||
(nodeName) =>
|
||||
!fixedNodeNameSet.has(nodeName) &&
|
||||
!params.toolNodeNameSet.has(nodeName) &&
|
||||
!isInstallationToolInstanceNodeName(nodeName),
|
||||
),
|
||||
)
|
||||
}
|
||||
@ -70,22 +81,20 @@ export function deriveInstallationFinishClosePlaybackStep(params: {
|
||||
const currentStepDoorNodeNames = configuredDoorNodeNames.filter((nodeName) =>
|
||||
(step.actions ?? []).some((action) => action.nodeNames.includes(nodeName)),
|
||||
)
|
||||
const hardwareNodeNameSet = collectHardwareNodeNames(numberingParts)
|
||||
const configuredCompanionHardwareNodeNames = collectFinishCloseCompanionHardwareNodeNames({
|
||||
const toolNodeNameSet = collectToolNodeNames(numberingParts)
|
||||
const finishCloseActionGroupNodeNames = collectFinishCloseActionGroupNodeNames({
|
||||
step,
|
||||
doorNodeNames: currentStepDoorNodeNames,
|
||||
hardwareNodeNameSet,
|
||||
toolNodeNameSet,
|
||||
})
|
||||
const configuredNodeNameSet = new Set(configuredDoorNodeNames)
|
||||
const expandedManualHingeCloseActions = (step.hingeCloseActions ?? []).map((action) => ({
|
||||
...action,
|
||||
nodeNames: uniqueNodeNames([
|
||||
...action.nodeNames,
|
||||
...collectFinishCloseCompanionHardwareNodeNames({
|
||||
step,
|
||||
doorNodeNames: action.nodeNames.filter((nodeName) => configuredNodeNameSet.has(nodeName)),
|
||||
hardwareNodeNameSet,
|
||||
}),
|
||||
...(action.nodeNames.some((nodeName) => configuredNodeNameSet.has(nodeName))
|
||||
? finishCloseActionGroupNodeNames
|
||||
: []),
|
||||
]),
|
||||
}))
|
||||
const manuallyClosingNodeNames = uniqueNodeNames(
|
||||
@ -93,23 +102,14 @@ export function deriveInstallationFinishClosePlaybackStep(params: {
|
||||
).filter((nodeName) => scopeNodeNameSet.has(nodeName))
|
||||
const holdExpandedNodeNames = uniqueNodeNames([
|
||||
...(step.holdExpandedNodeNames ?? []),
|
||||
...currentStepDoorNodeNames,
|
||||
...configuredCompanionHardwareNodeNames,
|
||||
...finishCloseActionGroupNodeNames,
|
||||
...manuallyClosingNodeNames,
|
||||
]).filter((nodeName) => scopeNodeNameSet.has(nodeName))
|
||||
if (holdExpandedNodeNames.length === 0) return step
|
||||
|
||||
const automaticClosingDoorNodeNames = currentStepDoorNodeNames.filter(
|
||||
const automaticClosingNodeNames = finishCloseActionGroupNodeNames.filter(
|
||||
(nodeName) => !manuallyClosingNodeNames.includes(nodeName),
|
||||
)
|
||||
const automaticClosingNodeNames = uniqueNodeNames([
|
||||
...automaticClosingDoorNodeNames,
|
||||
...collectFinishCloseCompanionHardwareNodeNames({
|
||||
step,
|
||||
doorNodeNames: automaticClosingDoorNodeNames,
|
||||
hardwareNodeNameSet,
|
||||
}),
|
||||
])
|
||||
const hingeCloseActions = automaticClosingNodeNames.length
|
||||
? [
|
||||
...expandedManualHingeCloseActions,
|
||||
|
||||
@ -307,10 +307,13 @@ export function buildInstallationStepSelectedActionEditorLocalTransforms(
|
||||
)
|
||||
const fixedNodeNames = new Set(step.fixedNodeNames)
|
||||
const holdExpandedNodeNames = new Set(step.holdExpandedNodeNames ?? [])
|
||||
const currentActionNodeNames = new Set(currentAction.nodeNames)
|
||||
|
||||
return (step.scopeNodeNames ?? []).flatMap((nodeName) => {
|
||||
const transform =
|
||||
holdExpandedNodeNames.has(nodeName)
|
||||
// A finish-close group remains expanded while other actions are edited,
|
||||
// but the selected action must retain its own editable start pose.
|
||||
holdExpandedNodeNames.has(nodeName) && !currentActionNodeNames.has(nodeName)
|
||||
? step.expandedTransforms?.[nodeName] ?? installedTransformMap.get(nodeName)
|
||||
: fixedNodeNames.has(nodeName) || priorActionNodeNames.has(nodeName)
|
||||
? installedTransformMap.get(nodeName)
|
||||
|
||||
@ -273,3 +273,47 @@ export function buildInstallationStepMaterialMarkerParts(
|
||||
) {
|
||||
return items.flatMap((item) => item.markerParts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build board markers for the active step's newly introduced materials.
|
||||
* The preview further scopes them to the currently playing action or fixed group.
|
||||
*/
|
||||
export function buildInstallationStepBoardAttentionParts({
|
||||
step,
|
||||
steps = [],
|
||||
stepParts,
|
||||
numberingParts,
|
||||
}: {
|
||||
step: InstallationStepDraft | null
|
||||
steps?: InstallationStepDraft[]
|
||||
stepParts: InstallationPartItem[]
|
||||
numberingParts: InstallationPartItem[]
|
||||
}): InstallationPartItem[] {
|
||||
if (!step) return []
|
||||
|
||||
const seenNodeNames = new Set<string>()
|
||||
|
||||
return buildInstallationStepMaterialItems({
|
||||
step,
|
||||
steps,
|
||||
stepParts,
|
||||
numberingParts,
|
||||
})
|
||||
.filter((item) => item.kind === 'part' && item.code.trim() && item.code.trim() !== '-')
|
||||
.flatMap((item) =>
|
||||
item.markerParts.flatMap((markerPart) =>
|
||||
uniqueStrings(markerPart.nodeNames).flatMap((nodeName) => {
|
||||
if (seenNodeNames.has(nodeName)) return []
|
||||
seenNodeNames.add(nodeName)
|
||||
return [{
|
||||
...markerPart,
|
||||
id: `board-attention:${markerPart.id}:${nodeName}`,
|
||||
nodeName,
|
||||
nodeNames: [nodeName],
|
||||
quantity: 1,
|
||||
overlayAnchorMode: 'center' as const,
|
||||
}]
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user