diff --git a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx index 301bdb16..0ec80c3d 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/InstallationDemoWorkbenchClient.tsx @@ -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} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx index 17793374..d6ed660b 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingMarkerLayer.tsx @@ -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(null) const [layerSize, setLayerSize] = useState({ 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 ( + + {marker.code} + + ) + } return ( diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx index 93a86309..f6a211cf 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationNumberingOverlay.tsx @@ -28,6 +28,9 @@ interface InstallationNumberingOverlayProps { onSelectPart: (partId: string) => void layoutVariant?: NumberingOverlayLayoutVariant safeInsets?: Partial + 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(null) const frameRef = useRef(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 (
{!isCameraInteracting ? ( - + ) : null}
) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx index 9e4c3289..0ecd111b 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationPreviewStage.tsx @@ -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 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(() => { + 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( (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 ? ( + {}} + layoutVariant="materials" + tone="part" + interactive={false} + followSceneTransforms={localTransformPlaybackActive} + /> ) : null } overlayRight={ diff --git a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationStepMaterialCard.tsx b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationStepMaterialCard.tsx index 5cd0c63b..7443ad44 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationStepMaterialCard.tsx +++ b/furniture-3d-system/src/app/installation-demo-workbench/components/InstallationStepMaterialCard.tsx @@ -62,9 +62,14 @@ export function InstallationStepMaterialCard({ {formatQuantity(item.quantity)} -

- {item.name || formatKindLabel(item.kind)} -

+
+ + {formatKindLabel(item.kind)} + +

+ {item.name || formatKindLabel(item.kind)} +

+
) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationPreviewController.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationPreviewController.ts index 2544d363..e9766539 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationPreviewController.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationPreviewController.ts @@ -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, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationRuntimeCategoryOutlines.ts b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationRuntimeCategoryOutlines.ts new file mode 100644 index 00000000..83c7b953 --- /dev/null +++ b/furniture-3d-system/src/app/installation-demo-workbench/hooks/useInstallationRuntimeCategoryOutlines.ts @@ -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 = { + 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() + 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]) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDemoConfigStorage.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDemoConfigStorage.ts index ac721b4f..b583a656 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDemoConfigStorage.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationDemoConfigStorage.ts @@ -1,10 +1,6 @@ 'use client' -/** - * 安装演示工作台统一配置存储(PostgreSQL) - * 所有浏览器读写同一份数据,按 drawingId 分组。 - * 替代原来的 localStorage 方案。 - */ +import { isInstallationDemoLocalRuntime } from './installationDemoRuntimeStorage' export interface InstallationDemoConfigPayload { parts?: unknown[] | Record @@ -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 { - 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) ?? null, + toolIds: Array.isArray(row.toolIds) ? row.toolIds : [], + uploadedModel: (row.uploadedModel as Record) ?? null, + explodeTemplate: (row.explodeTemplate as Record) ?? 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 { + 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) ?? null, - toolIds: Array.isArray(payload.data.toolIds) ? payload.data.toolIds : [], - uploadedModel: (payload.data.uploadedModel as Record) ?? null, - explodeTemplate: (payload.data.explodeTemplate as Record) ?? 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>() const pendingPayloads = new Map() 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) +} diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationFinishCloseBinding.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationFinishCloseBinding.ts index c177421c..909d8e9e 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationFinishCloseBinding.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationFinishCloseBinding.ts @@ -1,5 +1,6 @@ import type { InstallationPartItem, InstallationStepDraft } from '../types' import { getPartNodeNames } from './installationPartCatalogHelpers' +import { isInstallationToolInstanceNodeName } from './installationToolAttachmentIdentity' function uniqueNodeNames(nodeNames: Array) { 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 + toolNodeNameSet: Set }) { 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, diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionWorkflow.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionWorkflow.ts index 567be005..da341dfd 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionWorkflow.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepActionWorkflow.ts @@ -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) diff --git a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepMaterials.ts b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepMaterials.ts index 9aad5f55..bf3bfd26 100644 --- a/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepMaterials.ts +++ b/furniture-3d-system/src/app/installation-demo-workbench/lib/installationStepMaterials.ts @@ -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() + + 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, + }] + }), + ), + ) +}