feat(installation-demo): refine authored playback guidance

This commit is contained in:
WuYongJie 2026-07-28 09:55:52 +08:00
parent 117abf7df0
commit b4e6bb865c
11 changed files with 498 additions and 136 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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