feat(installation-demo): add part material editing workflow

This commit is contained in:
WuYongJie 2026-08-18 15:16:20 +08:00
parent 75f7b3ac7f
commit ec5875367a
29 changed files with 2314 additions and 35 deletions

View File

@ -34,6 +34,10 @@ import {
import { NUMBERING_ITEM_PLAYBACK_DURATION_MS } from '@/app/installation-demo-workbench/lib/installationNumberingOverlayConstants'
import { resolveInstallationNumberingPresentationDurationMs } from '@/app/installation-demo-workbench/lib/installationNumberingPresentationTiming'
import { resolveInstallationPreviewRuntimePresentation } from '@/app/installation-demo-workbench/lib/installationPreviewRuntimePresentation'
import {
buildInstallationNodeMaterialAssignments,
buildInstallationToolMaterialConfigsByToolId,
} from '@/app/installation-demo-workbench/lib/installationPartMaterialAssignments'
type ShareStage =
| { kind: 'finished'; label: string; durationMs: number }
@ -87,6 +91,14 @@ export function InstallationDemoShareViewerClient({
),
[snapshot.drawingTools.length, snapshot.numberingParts.length],
)
const materialAssignments = useMemo(
() => buildInstallationNodeMaterialAssignments(snapshot.nodeMaterialConfigs),
[snapshot.nodeMaterialConfigs],
)
const toolMaterialConfigsByToolId = useMemo(
() => buildInstallationToolMaterialConfigsByToolId(snapshot.nodeMaterialConfigs),
[snapshot.nodeMaterialConfigs],
)
const stages = useMemo(
() => buildShareStages(snapshot, numberingPresentationDurationMs),
[numberingPresentationDurationMs, snapshot],
@ -549,6 +561,8 @@ export function InstallationDemoShareViewerClient({
? previewRuntimePresentation.hiddenNodeNames
: []
}
materialAssignments={materialAssignments}
toolMaterialConfigsByToolId={toolMaterialConfigsByToolId}
modelNodeNames={modelNodeNames}
nodePositionOffsets={preview.animatedNodePositionOffsets}
nodeRotationStates={preview.animatedNodeRotationStates}

View File

@ -48,6 +48,10 @@ import {
buildHardwareCompositeTemplate,
resolveHardwareCompositeCandidateGroups,
} from "./lib/installationHardwareComposite";
import {
buildInstallationPartTexturePartsByKind,
resolveInstallationTextureToolId,
} from "./lib/installationPartTextureTargets";
import { applySingleToolDefaultBindings } from "./lib/installationToolBindingDefaults";
import {
readStoredInstallationNumberingViews,
@ -88,6 +92,8 @@ import { NUMBERING_ITEM_PLAYBACK_DURATION_MS } from "./lib/installationNumbering
import { useInstallationCatalogController } from "./hooks/useInstallationCatalogController";
import { useInstallationModelUploadController } from "./hooks/useInstallationModelUploadController";
import { useInstallationNumberingCatalog } from "./hooks/useInstallationNumberingCatalog";
import { useInstallationNodeMaterialConfig } from "./hooks/useInstallationNodeMaterialConfig";
import { useInstallationPartTexturePresentation } from "./hooks/useInstallationPartTexturePresentation";
import { useInstallationPartCatalog } from "./hooks/useInstallationPartCatalog";
import { useInstallationPreviewController } from "./hooks/useInstallationPreviewController";
import { useInstallationDrawingTools } from "./hooks/useInstallationDrawingTools";
@ -100,6 +106,8 @@ import { useInstallationStepEditor } from "./hooks/useInstallationStepEditor";
import { useInstallationStepMaterialThumbnails } from "./hooks/useInstallationStepMaterialThumbnails";
import { InstallationInspector } from "./components/InstallationInspector";
import { InstallationPreviewStage } from "./components/InstallationPreviewStage";
import { InstallationFinishedMiniPreview } from "./components/InstallationFinishedMiniPreview";
import { InstallationPartTexturePanel } from "./components/InstallationPartTexturePanel";
import { StepActionEditor } from "./components/StepActionEditor";
import { InstallationDrawingSheet } from "./components/InstallationDrawingSheet";
import { InstallationDrawingCaptureToolbar } from "./components/InstallationDrawingCaptureToolbar";
@ -120,6 +128,7 @@ import type {
import type {
InstallationPartRole,
InstallationPartItem,
InstallationPartKind,
InstallationWorkbenchMode,
InstallationWorkbenchView,
InstallationInspectorTab,
@ -175,6 +184,11 @@ export default function InstallationDemoWorkbenchClient({
const [numberingPreviewToolId, setNumberingPreviewToolId] = useState<
string | null
>(null);
const [textureCategory, setTextureCategory] =
useState<InstallationPartKind>("part");
const [selectedTexturePartId, setSelectedTexturePartId] = useState<
string | null
>(null);
const [explosionNumberingCategory, setExplosionNumberingCategory] =
useState<InstallationExplosionCategory>("part");
const [
@ -262,6 +276,10 @@ export default function InstallationDemoWorkbenchClient({
selectedDrawingId,
catalog.access.canEdit,
);
const nodeMaterials = useInstallationNodeMaterialConfig(
selectedDrawingId,
catalog.access.canEdit,
);
const uploadedModel = selectedDrawing
? (upload.uploadedModels[selectedDrawing.id] ?? null)
@ -525,6 +543,23 @@ export default function InstallationDemoWorkbenchClient({
),
[numberingCatalog.parts, numberingCategory],
);
const texturePartsByKind = useMemo(
() =>
buildInstallationPartTexturePartsByKind(
numberingCatalog.parts,
drawingTools.selectedTools,
),
[drawingTools.selectedTools, numberingCatalog.parts],
);
const textureParts = texturePartsByKind.part;
const activeTextureParts = texturePartsByKind[textureCategory];
const activeTexturePart = useMemo(
() =>
activeTextureParts.find((part) => part.id === selectedTexturePartId) ??
activeTextureParts[0] ??
null,
[activeTextureParts, selectedTexturePartId],
);
const numberingPreviewTool = useMemo<InstallationToolItem | null>(() => {
if (numberingCategory !== "tool") return null;
return (
@ -547,6 +582,15 @@ export default function InstallationDemoWorkbenchClient({
: -1,
[drawingTools.selectedTools, numberingPreviewTool],
);
const texturePreviewTool = useMemo(() => {
const toolId = resolveInstallationTextureToolId(activeTexturePart);
if (!toolId) return null;
return (
drawingTools.selectedTools.find((tool) => tool.id === toolId) ?? null
);
}, [activeTexturePart, drawingTools.selectedTools]);
const stagePreviewTool =
preview.stageView === "materials" ? texturePreviewTool : numberingPreviewTool;
const explosionNumberingParts = useMemo(
() =>
explosionNumberingCategory === "tool"
@ -734,6 +778,13 @@ export default function InstallationDemoWorkbenchClient({
],
);
const numberingHiddenNodeSignature = numberingHiddenNodeNames.join("\u001f");
const partTexturePresentation = useInstallationPartTexturePresentation({
active:
preview.stageView === "materials" && activeTexturePart?.kind !== "tool",
engine: previewEngine,
part: activeTexturePart,
candidateNodeNames: numberingHideCandidateNodeNames,
});
const previewRuntimePresentation =
resolveInstallationPreviewRuntimePresentation({
stageView: preview.stageView,
@ -752,6 +803,8 @@ export default function InstallationDemoWorkbenchClient({
const previewSelectedPart =
preview.stageView === "numbering"
? numberingCatalog.selectedPart
: preview.stageView === "materials"
? activeTexturePart
: partCatalog.selectedPart;
const savedNumberingView = selectedDrawing?.id
? (numberingViewsByDrawingId[selectedDrawing.id] ?? null)
@ -1093,7 +1146,9 @@ export default function InstallationDemoWorkbenchClient({
const selectableParts =
preview.stageView === "exploded"
? explosionNumberingParts
: numberingModelParts;
: preview.stageView === "materials"
? textureParts
: numberingModelParts;
const partIndex = selectableParts.findIndex((part) => part.id === partId);
if (partIndex >= 0) {
if (preview.stageView === "numbering") {
@ -1111,9 +1166,35 @@ export default function InstallationDemoWorkbenchClient({
numberingCatalog,
numberingModelParts,
preview.stageView,
textureParts,
],
);
const selectTexturePart = useCallback(
(partId: string) => {
setSelectedTexturePartId(partId);
const part = activeTextureParts.find((item) => item.id === partId);
setNumberingSelectedNodeNames(
part && part.kind !== "tool" ? getPartNodeNames(part) : [],
);
},
[activeTextureParts],
);
const selectTextureCategory = useCallback(
(kind: InstallationPartKind) => {
setTextureCategory(kind);
const firstPart = texturePartsByKind[kind][0] ?? null;
setSelectedTexturePartId(firstPart?.id ?? null);
setNumberingSelectedNodeNames(
firstPart && firstPart.kind !== "tool"
? getPartNodeNames(firstPart)
: [],
);
},
[texturePartsByKind],
);
const restoreSavedNumberingView = useCallback(
(view: CameraViewSnapshot | null) => {
if (!view) return false;
@ -1407,6 +1488,17 @@ export default function InstallationDemoWorkbenchClient({
return;
}
if (activeTab === "steps" && detailView === "materials") {
if (!nodeName) return;
const part = textureParts.find((item) =>
getPartNodeNames(item).includes(nodeName),
);
if (!part) return;
numberingCatalog.setSelectedPartId(part.id);
setNumberingSelectedNodeNames(getPartNodeNames(part));
return;
}
if (activeTab === "steps" && detailView === "steps") {
if (stepActionWorkspace.handleNodeSelect(payload)) return;
}
@ -1437,9 +1529,11 @@ export default function InstallationDemoWorkbenchClient({
mode,
partCatalog,
numberingCategory,
numberingCatalog,
selectionRole,
stepActionWorkspace,
stepEditor,
textureParts,
],
);
@ -1729,6 +1823,7 @@ export default function InstallationDemoWorkbenchClient({
numberingView: savedNumberingView,
numberingSnapshotUrls: shareNumberingSnapshotUrls,
numberingToolSnapshotUrls: shareNumberingToolSnapshotUrls,
nodeMaterialConfigs: nodeMaterials.nodeMaterialConfigs,
stepMaterialsByStepId,
steps: normalizedSteps,
drawingDocument:
@ -1785,6 +1880,7 @@ export default function InstallationDemoWorkbenchClient({
stepEditor.steps,
uploadedModel,
mode,
nodeMaterials.nodeMaterialConfigs,
],
);
@ -2200,20 +2296,18 @@ export default function InstallationDemoWorkbenchClient({
useEffect(() => {
const previousStageView = previousStageViewRef.current;
const engine = previewEngineRef.current ?? previewEngine;
const previousUsesNumberingCamera =
previousStageView === "numbering" || previousStageView === "materials";
const currentUsesNumberingCamera =
preview.stageView === "numbering" || preview.stageView === "materials";
if (
previousStageView !== "numbering" &&
preview.stageView === "numbering"
) {
if (!previousUsesNumberingCamera && currentUsesNumberingCamera) {
numberingEntryCameraViewRef.current = engine
? captureInstallationCameraView(engine)
: null;
}
if (
previousStageView === "numbering" &&
preview.stageView !== "numbering"
) {
if (previousUsesNumberingCamera && !currentUsesNumberingCamera) {
const entryView = numberingEntryCameraViewRef.current;
numberingEntryCameraViewRef.current = null;
autoAppliedNumberingViewKeyRef.current = null;
@ -2230,7 +2324,9 @@ export default function InstallationDemoWorkbenchClient({
}, [preview.stageView, previewEngine]);
useEffect(() => {
if (preview.stageView !== "numbering") {
if (
preview.stageView !== "numbering"
) {
autoAppliedNumberingViewKeyRef.current = null;
return;
}
@ -2261,6 +2357,8 @@ export default function InstallationDemoWorkbenchClient({
? explosionHiddenNodeNames
: preview.stageView === "numbering"
? numberingHiddenNodeNames
: preview.stageView === "materials"
? partTexturePresentation.hiddenNodeNames
: preview.stageView === "preview"
? previewRuntimePresentation.hiddenNodeNames
: preview.stageView === "steps" &&
@ -2326,7 +2424,12 @@ export default function InstallationDemoWorkbenchClient({
<InstallationPreviewStage
drawing={catalog.selectedDrawing}
modelUrl={modelUrl}
previewTool={numberingPreviewTool}
previewTool={stagePreviewTool}
previewToolMaterial={
preview.stageView === "materials"
? nodeMaterials.resolvePartMaterial(activeTexturePart)
: null
}
selectedNodeNames={
drawingCapturePresentationActive ? [] : stepStageSelectedNodeNames
}
@ -2334,7 +2437,11 @@ export default function InstallationDemoWorkbenchClient({
drawingCapturePresentationActive ? [] : explosionSelectedNodeNames
}
numberingSelectedNodeNames={
drawingCapturePresentationActive ? [] : numberingSelectedNodeNames
drawingCapturePresentationActive
? []
: preview.stageView === "materials"
? partTexturePresentation.visibleNodeNames
: numberingSelectedNodeNames
}
completedNodeNames={
drawingCapturePresentationActive ? [] : preview.completedNodeNames
@ -2348,6 +2455,8 @@ export default function InstallationDemoWorkbenchClient({
: (drawingActionCaptureFrame?.hiddenNodeNames ??
activeStageHiddenNodeNames)
}
materialAssignments={nodeMaterials.materialAssignments}
toolMaterialConfigsByToolId={nodeMaterials.toolMaterialConfigsByToolId}
modelNodeNames={modelNodeNames}
nodePositionOffsets={
drawingActionCaptureActive ? [] : preview.animatedNodePositionOffsets
@ -2422,7 +2531,11 @@ export default function InstallationDemoWorkbenchClient({
onSelectionRoleChange={setSelectionRole}
onNodeSelect={handleNodeSelect}
onRemoveActionTool={stepActionWorkspace.removeActiveActionTool}
onSelectPart={selectNumberingPart}
onSelectPart={
preview.stageView === "materials"
? selectTexturePart
: selectNumberingPart
}
onSelectDrawingTool={(toolId) => {
setNumberingCategory("tool");
setNumberingPreviewToolId(toolId);
@ -2443,6 +2556,7 @@ export default function InstallationDemoWorkbenchClient({
onEngineReady={({ engine }) => {
previewEngineRef.current = engine;
setPreviewEngine(engine);
if (stagePreviewTool) return;
syncModelGeometry(engine);
}}
onCaptureReady={handlePreviewCaptureReady}
@ -2748,6 +2862,33 @@ export default function InstallationDemoWorkbenchClient({
selectedNodeCount={selectedExplosionPartCount}
selectedNumberingNodeNames={numberingSelectedNodeNames}
stepActionEditor={stepActionEditor}
finishedPreview={
<InstallationFinishedMiniPreview
modelUrl={modelUrl}
materialAssignments={nodeMaterials.materialAssignments}
/>
}
partTextureEditor={
<InstallationPartTexturePanel
partsByKind={texturePartsByKind}
activeKind={textureCategory}
activePart={activeTexturePart}
activeMaterial={nodeMaterials.resolvePartMaterial(activeTexturePart)}
canEdit={catalog.access.canEdit}
loading={nodeMaterials.loading}
saving={nodeMaterials.saving}
dirty={nodeMaterials.dirty}
message={nodeMaterials.message}
onKindChange={selectTextureCategory}
onSelectPart={selectTexturePart}
onApplyMaterial={nodeMaterials.applyMaterialToParts}
onPatchMaterials={nodeMaterials.patchMaterialsForParts}
onClearMaterials={nodeMaterials.clearMaterialsForParts}
onSave={() => {
void nodeMaterials.save();
}}
/>
}
shareUrl={shareUrl}
shareExpiresAt={shareExpiresAt}
shareLoading={shareLoading}
@ -2767,6 +2908,18 @@ export default function InstallationDemoWorkbenchClient({
setNumberingPreviewToolId(null);
setNumberingRevealCount(numberingCategoryCounts.part > 0 ? 1 : 0);
}
if (nextView === "materials") {
const firstTexturePart =
textureParts.find(
(part) => part.id === numberingCatalog.selectedPartId,
) ?? textureParts[0] ?? null;
setNumberingCategory("part");
setNumberingPreviewToolId(null);
numberingCatalog.setSelectedPartId(firstTexturePart?.id ?? null);
setNumberingSelectedNodeNames(
firstTexturePart ? getPartNodeNames(firstTexturePart) : [],
);
}
if (nextView === "exploded") {
setExplosionNumberingCategory("part");
setNumberingSelectedNodeNames([]);

View File

@ -77,6 +77,8 @@ interface InstallationDetailsPanelProps {
onRemoveStepGroupRef: (groupRefId: string) => void
onGenerateDrawing: () => void
stepActionEditor?: ReactNode
finishedPreview?: ReactNode
partTextureEditor?: ReactNode
}
const DETAIL_STAGE_ITEMS: Array<{
@ -87,7 +89,8 @@ const DETAIL_STAGE_ITEMS: Array<{
{ value: 'finished', title: '第一步:成品展示', description: '固定展示产品安装完成后的样子。' },
{ value: 'exploded', title: '第二步:爆炸图', description: '固定展示产品拆开的状态。' },
{ value: 'numbering', title: '第三步:部件编号', description: '按编号顺序逐组展示,并支持折角引线标注。' },
{ value: 'steps', title: '第四步:安装步骤', description: '进入后续安装步骤与动画设置。' },
{ value: 'materials', title: '第四步:部件贴图', description: '按板件编号绑定颜色、纹理和方向。' },
{ value: 'steps', title: '第五步:安装步骤', description: '进入后续安装步骤与动画设置。' },
]
const NUMBERING_CATEGORY_TABS: Array<{
@ -250,6 +253,8 @@ export function InstallationDetailsPanel({
onRemoveStepGroupRef,
onGenerateDrawing,
stepActionEditor,
finishedPreview,
partTextureEditor,
}: InstallationDetailsPanelProps) {
return (
<div className="flex min-h-0 flex-1 flex-col">
@ -289,6 +294,10 @@ export function InstallationDetailsPanel({
</div>
</section>
{detailView === 'numbering' || detailView === 'materials' ? (
<div className="mb-3">{finishedPreview}</div>
) : null}
{detailView === 'finished' ? (
<DetailPhaseSummary
title="成品展示"
@ -391,6 +400,8 @@ export function InstallationDetailsPanel({
</div>
) : null}
{detailView === 'materials' ? partTextureEditor : null}
{detailView === 'steps' ? (
<StepEditingPanel
steps={steps}

View File

@ -0,0 +1,40 @@
'use client'
import { Box } from 'lucide-react'
import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel'
import type { MaterialConfig } from '@/lib/3d/material-manager'
interface InstallationFinishedMiniPreviewProps {
modelUrl: string | null
materialAssignments: Array<{ nodeName: string; material: MaterialConfig }>
}
export function InstallationFinishedMiniPreview({
modelUrl,
materialAssignments,
}: InstallationFinishedMiniPreviewProps) {
return (
<section className="overflow-hidden rounded-[8px] border border-black/[0.08] bg-[#eef1f2]">
<div className="flex h-8 items-center justify-between border-b border-black/[0.06] bg-white px-3">
<span className="text-[11px] font-black text-[#2d3335]">成品预览</span>
<span className="text-[9px] font-semibold text-[#8b9092]">实时同步</span>
</div>
<div className="aspect-[16/9] min-h-[156px]">
<ModelPreviewPanel
modelUrl={modelUrl}
materialAssignments={materialAssignments}
title="成品预览"
className="h-full"
showFpsBadge={false}
showCameraActionButtons={false}
viewportControlsEnabled
defaultShowSceneGrid={false}
emptyTitle="暂无成品模型"
emptyDescription="选择带模型的图纸后显示成品效果。"
emptyIcon={<Box size={24} strokeWidth={1.6} />}
/>
</div>
</section>
)
}

View File

@ -108,6 +108,8 @@ interface InstallationInspectorProps {
onRemoveStepGroupRef: (groupRefId: string) => void
onGenerateDrawing: () => void
stepActionEditor?: ReactNode
finishedPreview?: ReactNode
partTextureEditor?: ReactNode
}
const TABS: Array<{ value: InstallationInspectorTab; label: string }> = [
@ -204,6 +206,8 @@ export function InstallationInspector({
onRemoveStepGroupRef,
onGenerateDrawing,
stepActionEditor,
finishedPreview,
partTextureEditor,
}: InstallationInspectorProps) {
return (
<aside className="flex h-full w-[460px] shrink-0 flex-col border-l border-black/[0.06] bg-white">
@ -328,6 +332,8 @@ export function InstallationInspector({
onRemoveStepGroupRef={onRemoveStepGroupRef}
onGenerateDrawing={onGenerateDrawing}
stepActionEditor={stepActionEditor}
finishedPreview={finishedPreview}
partTextureEditor={partTextureEditor}
/>
) : null}

View File

@ -0,0 +1,68 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { InstallationPartItem } from '../types'
import { InstallationPartTextureNavigator } from './InstallationPartTextureNavigator'
function createPart(index: number, kind: InstallationPartItem['kind']): InstallationPartItem {
return {
id: `${kind}-${index}`,
nodeName: `${kind}-node-${index}`,
nodeNames: [`${kind}-node-${index}`],
code: String(index),
name: `${kind}-${index}`,
quantity: 1,
kind,
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-18T00:00:00.000Z',
}
}
describe('InstallationPartTextureNavigator', () => {
it('jumps directly to the requested sequence number', () => {
const parts = Array.from({ length: 27 }, (_, index) => createPart(index + 1, 'part'))
const onSelectPart = jest.fn()
render(
<InstallationPartTextureNavigator
partsByKind={{
part: parts,
hardware: [createPart(1, 'hardware')],
tool: [createPart(1, 'tool')],
}}
activeKind="part"
activePart={parts[0]}
onKindChange={jest.fn()}
onSelectPart={onSelectPart}
/>,
)
const indexInput = screen.getByRole('textbox', { name: '跳转到编号序号' })
fireEvent.change(indexInput, { target: { value: '13' } })
fireEvent.keyDown(indexInput, { key: 'Enter' })
expect(onSelectPart).toHaveBeenCalledWith('part-13')
})
it('exposes all populated part categories', () => {
const onKindChange = jest.fn()
render(
<InstallationPartTextureNavigator
partsByKind={{
part: [createPart(1, 'part')],
hardware: [createPart(1, 'hardware')],
tool: [createPart(1, 'tool')],
}}
activeKind="part"
activePart={createPart(1, 'part')}
onKindChange={onKindChange}
onSelectPart={jest.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '五金 1' }))
fireEvent.click(screen.getByRole('button', { name: '工具 1' }))
expect(onKindChange.mock.calls).toEqual([['hardware'], ['tool']])
})
})

View File

@ -0,0 +1,117 @@
'use client'
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { InstallationPartItem, InstallationPartKind } from '../types'
import type { InstallationPartTexturePartsByKind } from '../lib/installationPartTextureTargets'
interface InstallationPartTextureNavigatorProps {
partsByKind: InstallationPartTexturePartsByKind
activeKind: InstallationPartKind
activePart: InstallationPartItem | null
onKindChange: (kind: InstallationPartKind) => void
onSelectPart: (partId: string) => void
}
const KIND_OPTIONS: Array<{ kind: InstallationPartKind; label: string }> = [
{ kind: 'part', label: '板材' },
{ kind: 'hardware', label: '五金' },
{ kind: 'tool', label: '工具' },
]
export function InstallationPartTextureNavigator({
partsByKind,
activeKind,
activePart,
onKindChange,
onSelectPart,
}: InstallationPartTextureNavigatorProps) {
const parts = partsByKind[activeKind]
const activeIndex = activePart ? parts.findIndex((part) => part.id === activePart.id) : -1
const [indexDraft, setIndexDraft] = useState(activeIndex >= 0 ? String(activeIndex + 1) : '')
const selectIndex = (nextIndex: number) => {
const part = parts[Math.min(parts.length - 1, Math.max(0, nextIndex))]
if (part) onSelectPart(part.id)
}
const commitIndex = () => {
const requestedIndex = Math.trunc(Number(indexDraft))
if (!Number.isFinite(requestedIndex) || parts.length === 0) {
setIndexDraft(activeIndex >= 0 ? String(activeIndex + 1) : '')
return
}
selectIndex(requestedIndex - 1)
}
return (
<section className="border-y border-black/[0.06] bg-white py-2">
<div className="grid grid-cols-3 gap-1 rounded-[8px] bg-[#eef2f3] p-1">
{KIND_OPTIONS.map((option) => (
<button
key={option.kind}
type="button"
aria-pressed={activeKind === option.kind}
disabled={partsByKind[option.kind].length === 0}
onClick={() => onKindChange(option.kind)}
className={`h-8 rounded-[6px] text-[10px] font-black transition ${
activeKind === option.kind
? 'bg-[#2d3335] text-white shadow-sm'
: 'text-[#6a7072] hover:bg-white/75'
} disabled:cursor-not-allowed disabled:opacity-35`}
>
{option.label} {partsByKind[option.kind].length}
</button>
))}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<button
type="button"
title="上一个部件"
aria-label="上一个部件"
disabled={activeIndex <= 0}
onClick={() => selectIndex(activeIndex - 1)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] text-[#5a6062] transition hover:bg-black/[0.04] disabled:opacity-30"
>
<ChevronLeft size={17} strokeWidth={1.9} />
</button>
<div className="min-w-0 flex-1 text-center">
<p className="truncate text-[13px] font-black text-[#2d3335]">
{activePart ? `${activePart.code} · ${activePart.name}` : `暂无${KIND_OPTIONS.find((item) => item.kind === activeKind)?.label ?? '部件'}`}
</p>
<div className="mt-1 flex h-7 items-center justify-center gap-1 text-[10px] font-semibold text-[#8b9092]">
<input
aria-label="跳转到编号序号"
inputMode="numeric"
value={indexDraft}
disabled={parts.length === 0}
onChange={(event) => setIndexDraft(event.target.value.replace(/[^0-9]/g, ''))}
onBlur={commitIndex}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commitIndex()
event.currentTarget.blur()
}
}}
className="h-7 w-11 rounded-[6px] border border-black/[0.09] bg-white px-1 text-center text-[11px] font-black text-[#2d3335] outline-none focus:border-[#2d3335] disabled:opacity-45"
/>
<span>/ {parts.length}</span>
</div>
</div>
<button
type="button"
title="下一个部件"
aria-label="下一个部件"
disabled={activeIndex < 0 || activeIndex >= parts.length - 1}
onClick={() => selectIndex(activeIndex + 1)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px] text-[#5a6062] transition hover:bg-black/[0.04] disabled:opacity-30"
>
<ChevronRight size={17} strokeWidth={1.9} />
</button>
</div>
</section>
)
}

View File

@ -0,0 +1,304 @@
'use client'
import { useMemo, useState } from 'react'
import { Eraser, LoaderCircle, Save } from 'lucide-react'
import Image from '@/components/ui/Image'
import type { MaterialConfig } from '@/lib/3d/material-manager'
import { mergeMaterialWithPreset } from '@/lib/3d/material-pbr-presets'
import { buildMaterialThumbnailPreviewUrl } from '@/lib/material-thumbnail'
import { useInstallationMaterialLibrary } from '../hooks/useInstallationMaterialLibrary'
import type { InstallationPartItem, InstallationPartKind } from '../types'
import type { InstallationPartTexturePartsByKind } from '../lib/installationPartTextureTargets'
import { InstallationPartTextureNavigator } from './InstallationPartTextureNavigator'
import { InstallationPartTextureUvEditor } from './InstallationPartTextureUvEditor'
type InstallationPartTextureApplyScope = 'part' | 'all'
interface InstallationPartTexturePanelProps {
partsByKind: InstallationPartTexturePartsByKind
activeKind: InstallationPartKind
activePart: InstallationPartItem | null
activeMaterial: MaterialConfig | null
canEdit: boolean
loading: boolean
saving: boolean
dirty: boolean
message: string | null
onKindChange: (kind: InstallationPartKind) => void
onSelectPart: (partId: string) => void
onApplyMaterial: (parts: InstallationPartItem[], material: MaterialConfig) => void
onPatchMaterials: (
parts: InstallationPartItem[],
updater: (material: MaterialConfig, part: InstallationPartItem) => MaterialConfig,
) => void
onClearMaterials: (parts: InstallationPartItem[]) => void
onSave: () => void
}
export function InstallationPartTexturePanel({
partsByKind,
activeKind,
activePart,
activeMaterial,
canEdit,
loading,
saving,
dirty,
message,
onKindChange,
onSelectPart,
onApplyMaterial,
onPatchMaterials,
onClearMaterials,
onSave,
}: InstallationPartTexturePanelProps) {
const {
data: rawCategories,
isLoading: materialsLoading,
error,
} = useInstallationMaterialLibrary()
const categories = useMemo(
() =>
rawCategories
.map((category) => ({
...category,
materials: category.materials.map((material) =>
mergeMaterialWithPreset(material, { forcePreset: false }),
),
}))
.filter((category) => category.materials.length > 0),
[rawCategories],
)
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
const [applyScope, setApplyScope] =
useState<InstallationPartTextureApplyScope>('part')
const [materialSelectionRevision, setMaterialSelectionRevision] = useState(0)
const parts = partsByKind[activeKind]
const resolvedApplyScope = activeKind === 'part' ? applyScope : 'part'
const activeMaterialCategory = activeMaterial
? categories.find((item) => item.id === activeMaterial.categoryId) ?? null
: null
const activeCategory =
categories.find((category) => category.id === selectedCategoryId) ??
activeMaterialCategory ??
categories[0] ??
null
const targetParts = useMemo(
() =>
resolvedApplyScope === 'all'
? partsByKind.part
: activePart
? [activePart]
: [],
[activePart, partsByKind.part, resolvedApplyScope],
)
const targetKindLabel =
activeKind === 'part' ? '板材' : activeKind === 'hardware' ? '五金' : '工具'
const targetLabel =
resolvedApplyScope === 'all'
? `整件模型 · ${partsByKind.part.length} 个板件编号`
: activePart
? `当前${targetKindLabel} · ${activePart.code}`
: `当前${targetKindLabel}`
return (
<div className="grid gap-3">
<InstallationPartTextureNavigator
key={`${activeKind}:${activePart?.id ?? 'none'}`}
partsByKind={partsByKind}
activeKind={activeKind}
activePart={activePart}
onKindChange={onKindChange}
onSelectPart={onSelectPart}
/>
{activeKind === 'part' ? (
<section>
<div className="mx-auto grid w-full max-w-[252px] grid-cols-2 gap-1 rounded-[8px] bg-[#eef2f3] p-1">
{([
{ value: 'part', label: '当前编号' },
{ value: 'all', label: '整件模型' },
] as const).map((option) => (
<button
key={option.value}
type="button"
aria-pressed={applyScope === option.value}
disabled={parts.length === 0}
onClick={() => setApplyScope(option.value)}
className={`h-7 rounded-[6px] text-[10px] font-black transition ${
applyScope === option.value
? 'bg-[#2d3335] text-white shadow-sm'
: 'text-[#6a7072] hover:bg-white/75'
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{option.label}
</button>
))}
</div>
<p className="mt-1.5 text-center text-[9px] font-semibold text-[#8b9092]">
{applyScope === 'all'
? `贴图同步到 ${partsByKind.part.length} 个已编号板件;主画布仍只显示当前编号`
: '贴图只作用于当前板件编号'}
</p>
</section>
) : (
<p className="text-center text-[9px] font-semibold text-[#8b9092]">
贴图只作用于当前{targetKindLabel},不会覆盖板材
</p>
)}
{materialsLoading || loading ? (
<div className="flex min-h-28 items-center justify-center gap-2 text-[11px] font-semibold text-[#8b9092]">
<LoaderCircle size={15} className="animate-spin" />
载入材质库
</div>
) : error ? (
<div className="border border-[#e7b6ad] bg-[#fff5f2] px-3 py-2 text-[11px] font-semibold text-[#9a3f36]">
材质库读取失败
</div>
) : activePart ? (
<>
<section>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-[11px] font-black text-[#2d3335]">快速更改基材</p>
<p className="truncate text-[9px] font-semibold text-[#8b9092]">{targetLabel}</p>
</div>
<div className="flex gap-1 overflow-x-auto pb-2">
{categories.map((category) => (
<button
key={category.id}
type="button"
onClick={() => setSelectedCategoryId(category.id)}
className={`h-8 shrink-0 rounded-[6px] px-3 text-[11px] font-black transition ${
activeCategory?.id === category.id
? 'bg-[#2d3335] text-white'
: 'bg-[#eef2f3] text-[#5a6062] hover:bg-[#e3e8e9]'
}`}
>
{category.name}
</button>
))}
</div>
<div className="overflow-x-auto pb-1">
<div className="grid grid-flow-col auto-cols-[calc((100%-1.5rem)/4)] grid-rows-1 gap-2">
{(activeCategory?.materials ?? []).map((material) => {
const selected = activeMaterial?.id === material.id
return (
<button
key={material.id}
type="button"
title={`${material.name} · 应用到${targetLabel}`}
disabled={!canEdit || targetParts.length === 0}
onClick={() => {
onApplyMaterial(targetParts, material)
setMaterialSelectionRevision((current) => current + 1)
}}
className={`min-w-0 overflow-hidden rounded-[6px] border bg-white text-left transition ${
selected
? 'border-[#2d3335] ring-1 ring-[#2d3335]/25'
: 'border-black/[0.08] hover:border-black/20'
} disabled:cursor-not-allowed disabled:opacity-60`}
>
<span className="relative block aspect-square overflow-hidden bg-[#eef1f2]">
<Image
src={buildMaterialThumbnailPreviewUrl(material, { preferThumbnail: true })}
alt={material.name}
className="h-full w-full object-cover"
rounded="none"
lazy={false}
draggable={false}
/>
</span>
<span className="block truncate px-1.5 py-1 text-[9px] font-bold text-[#5a6062]">
{material.name}
</span>
</button>
)
})}
</div>
</div>
</section>
{activeMaterial ? (
<>
<section className="grid gap-2 border-t border-black/[0.06] pt-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[11px] font-black text-[#2d3335]">快速更改颜色</p>
<p className="mt-0.5 text-[9px] font-semibold text-[#8b9092]">
保留当前基材和渲染参数
</p>
</div>
<div className="flex items-center gap-2">
<input
id="part-texture-color"
aria-label="部件贴图颜色"
type="color"
value={activeMaterial.color}
disabled={!canEdit}
onChange={(event) => {
const color = event.target.value
onPatchMaterials(targetParts, (material) => ({ ...material, color }))
}}
className="h-8 w-11 cursor-pointer border-0 bg-transparent p-0 disabled:cursor-not-allowed"
/>
<span className="w-[58px] text-right text-[10px] font-semibold text-[#8b9092]">
{activeMaterial.color.toUpperCase()}
</span>
</div>
</div>
</section>
<InstallationPartTextureUvEditor
key={`${activeKind}:${resolvedApplyScope}:${activePart.id}:${activeMaterial.id}:${materialSelectionRevision}`}
material={activeMaterial}
targetLabel={targetLabel}
canEdit={canEdit}
onPatchMaterial={(updater) => onPatchMaterials(targetParts, updater)}
/>
</>
) : (
<div className="border-t border-black/[0.06] py-4 text-center text-[11px] font-semibold text-[#8b9092]">
先选择一种基材,再调整颜色与完整渲染参数。
</div>
)}
</>
) : (
<div className="py-8 text-center text-[11px] font-semibold text-[#8b9092]">
当前图纸还没有可调整的{targetKindLabel}。
</div>
)}
<section className="flex items-center justify-between gap-2 border-t border-black/[0.06] pt-3">
<div className="min-w-0">
<p className="truncate text-[10px] font-semibold text-[#8b9092]">
{message ?? (dirty ? '有未保存的贴图调整' : '当前贴图已固定')}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
title={resolvedApplyScope === 'all' ? '清除整件板件贴图' : `清除当前${targetKindLabel}贴图`}
aria-label={resolvedApplyScope === 'all' ? '清除整件板件贴图' : `清除当前${targetKindLabel}贴图`}
disabled={!canEdit || targetParts.length === 0 || !activeMaterial}
onClick={() => onClearMaterials(targetParts)}
className="flex h-9 w-9 items-center justify-center rounded-[6px] text-[#6a7072] transition hover:bg-black/[0.04] disabled:opacity-30"
>
<Eraser size={15} strokeWidth={1.8} />
</button>
<button
type="button"
disabled={!canEdit || !dirty || saving}
onClick={onSave}
className="inline-flex h-9 items-center gap-1.5 rounded-[6px] bg-[#2d3335] px-3 text-[11px] font-black text-white transition hover:bg-[#1f2426] disabled:cursor-not-allowed disabled:opacity-35"
>
{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}
最终保存
</button>
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { MaterialConfig } from '@/lib/3d/material-manager'
import { InstallationPartTextureUvEditor } from './InstallationPartTextureUvEditor'
const material: MaterialConfig = {
id: 'wood-walnut',
name: '胡桃色',
categoryId: 'wood',
color: '#8b5a3c',
metalness: 0,
roughness: 0.62,
opacity: 1,
textureRepeat: 2.5,
textureRepeatU: 1,
textureRepeatV: 1,
textureRotation: 0,
textureSizeMm: 600,
}
describe('InstallationPartTextureUvEditor', () => {
it('keeps common controls visible and advanced density controls collapsed', () => {
render(
<InstallationPartTextureUvEditor
material={material}
targetLabel="板件编号 1"
canEdit
onPatchMaterial={jest.fn()}
/>,
)
expect(screen.getByText('纹理调整')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '自动' })).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '纹理尺寸 mm' })).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: '旋转角度 °' })).toBeInTheDocument()
expect(screen.queryByRole('textbox', { name: '纹理密度' })).not.toBeInTheDocument()
expect(screen.queryByText('暂存渲染参数')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '展开高级调整' }))
expect(screen.getByRole('textbox', { name: '纹理密度' })).toHaveValue('2.5')
expect(screen.getByRole('textbox', { name: '横向密度' })).toHaveValue('1')
expect(screen.getByRole('textbox', { name: '纵向密度' })).toHaveValue('1')
})
it('continues to patch the material while the final save remains outside the editor', () => {
const onPatchMaterial = jest.fn()
render(
<InstallationPartTextureUvEditor
material={material}
targetLabel="板件编号 1"
canEdit
onPatchMaterial={onPatchMaterial}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '增加纹理尺寸 mm' }))
const updater = onPatchMaterial.mock.calls[0]?.[0]
expect(updater(material)).toMatchObject({ textureSizeMm: 610 })
expect(screen.queryByRole('button', { name: '最终保存' })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,231 @@
'use client'
import { useState } from 'react'
import { ChevronDown, RotateCcw, Trash2 } from 'lucide-react'
import type { MaterialConfig } from '@/lib/3d/material-manager'
import {
clearInstallationTextureUvFormState,
createInstallationTextureUvFormState,
mergeInstallationTextureUvFormState,
type InstallationTextureProjectionAxis,
type InstallationTextureUvFormState,
} from '../lib/installationPartTextureUvEditor'
import { InstallationTextureNumericStepper } from './InstallationTextureNumericStepper'
interface InstallationPartTextureUvEditorProps {
material: MaterialConfig
targetLabel: string
canEdit: boolean
onPatchMaterial: (updater: (material: MaterialConfig) => MaterialConfig) => void
}
const AXIS_OPTIONS: Array<{ value: InstallationTextureProjectionAxis; label: string }> = [
{ value: '', label: '自动' },
{ value: 'x', label: 'X' },
{ value: 'y', label: 'Y' },
{ value: 'z', label: 'Z' },
]
interface UvFieldConfig {
field: keyof Omit<InstallationTextureUvFormState, 'textureProjectionAxis'>
label: string
unit?: string
placeholder: string
step: number
min?: number
max?: number
}
const PRIMARY_FIELDS: UvFieldConfig[] = [
{ field: 'textureSizeMm', label: '纹理尺寸', unit: 'mm', placeholder: '600', step: 10, min: 10, max: 10000 },
{ field: 'textureRotation', label: '旋转角度', unit: '°', placeholder: '0', step: 1, min: -360, max: 360 },
]
const ADVANCED_FIELDS: UvFieldConfig[] = [
{ field: 'textureRepeat', label: '纹理密度', placeholder: '1.0', step: 0.1, min: 0.1, max: 100 },
{ field: 'textureRepeatU', label: '横向密度', placeholder: '1.0', step: 0.1, min: 0.1, max: 100 },
{ field: 'textureRepeatV', label: '纵向密度', placeholder: '1.0', step: 0.1, min: 0.1, max: 100 },
]
function UvField({
item,
value,
canEdit,
onChange,
}: {
item: UvFieldConfig
value: string
canEdit: boolean
onChange: (value: string) => void
}) {
return (
<div className="min-w-0">
<label
htmlFor={`installation-texture-${item.field}`}
className="mb-1.5 flex items-baseline gap-1 text-[10px] font-black text-[#5a6062]"
>
{item.label}
{item.unit ? <span className="text-[9px] text-[#9aa0a2]">{item.unit}</span> : null}
</label>
<InstallationTextureNumericStepper
id={`installation-texture-${item.field}`}
label={`${item.label}${item.unit ? ` ${item.unit}` : ''}`}
value={value}
placeholder={item.placeholder}
step={item.step}
min={item.min}
max={item.max}
disabled={!canEdit}
onChange={onChange}
/>
</div>
)
}
export function InstallationPartTextureUvEditor({
material,
targetLabel,
canEdit,
onPatchMaterial,
}: InstallationPartTextureUvEditorProps) {
const initialForm = createInstallationTextureUvFormState(material)
const [form, setForm] = useState(initialForm)
const [baselineForm, setBaselineForm] = useState(initialForm)
const [advancedOpen, setAdvancedOpen] = useState(false)
const dirty = JSON.stringify(form) !== JSON.stringify(baselineForm)
const applyForm = (nextForm: InstallationTextureUvFormState) => {
setForm(nextForm)
onPatchMaterial((currentMaterial) =>
mergeInstallationTextureUvFormState(currentMaterial, nextForm),
)
}
const clearActiveAxis = () => {
const clearedForm = clearInstallationTextureUvFormState(form.textureProjectionAxis)
const nextMaterial = mergeInstallationTextureUvFormState(material, clearedForm)
const nextAxis = nextMaterial.textureProjectionAxis ?? ''
setForm(createInstallationTextureUvFormState(nextMaterial, nextAxis))
onPatchMaterial(() => nextMaterial)
}
const selectAxis = (axis: InstallationTextureProjectionAxis) => {
const nextForm = createInstallationTextureUvFormState(material, axis)
setForm(nextForm)
setBaselineForm(nextForm)
}
const advancedSummary = [
`密度 ${form.textureRepeat || '1.0'}`,
`横 ${form.textureRepeatU || '1.0'}`,
`纵 ${form.textureRepeatV || '1.0'}`,
].join(' · ')
return (
<section className="grid w-full max-w-[calc(100vw-26px)] gap-3 border-t border-black/[0.06] pt-3">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-black text-[#2d3335]">纹理调整</p>
<p className="truncate text-[9px] font-semibold text-[#8b9092]">{targetLabel}</p>
</div>
<div className="flex items-center gap-1">
<button
type="button"
title="还原当前纹理调整"
aria-label="还原当前纹理调整"
disabled={!canEdit || !dirty}
onClick={() => applyForm(baselineForm)}
className="flex h-8 w-8 items-center justify-center rounded-[6px] text-[#6a7072] transition hover:bg-black/[0.04] disabled:opacity-30"
>
<RotateCcw size={14} strokeWidth={1.8} />
</button>
<button
type="button"
title="清空当前纹理参数"
aria-label="清空当前纹理参数"
disabled={!canEdit}
onClick={clearActiveAxis}
className="flex h-8 w-8 items-center justify-center rounded-[6px] text-[#6a7072] transition hover:bg-black/[0.04] disabled:opacity-30"
>
<Trash2 size={14} strokeWidth={1.8} />
</button>
</div>
</div>
<div>
<p className="mb-1.5 text-[10px] font-black text-[#5a6062]">投影方向</p>
<div className="grid h-9 grid-cols-4 gap-1 rounded-[7px] bg-[#eef2f3] p-1">
{AXIS_OPTIONS.map((option) => (
<button
key={option.value || 'auto'}
type="button"
aria-label={option.value ? `${option.label} 轴` : '自动'}
aria-pressed={form.textureProjectionAxis === option.value}
disabled={!canEdit}
onClick={() => selectAxis(option.value)}
className={`h-7 rounded-[5px] text-[10px] font-black transition ${
form.textureProjectionAxis === option.value
? 'bg-[#2d3335] text-white shadow-sm'
: 'text-[#6a7072] hover:bg-white/75'
} disabled:cursor-not-allowed disabled:opacity-50`}
>
{option.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{PRIMARY_FIELDS.map((item) => (
<UvField
key={item.field}
item={item}
value={form[item.field]}
canEdit={canEdit}
onChange={(value) => applyForm({ ...form, [item.field]: value })}
/>
))}
</div>
<div className="border-y border-black/[0.06]">
<button
type="button"
aria-expanded={advancedOpen}
aria-controls="installation-texture-advanced"
aria-label={advancedOpen ? '收起高级调整' : '展开高级调整'}
onClick={() => setAdvancedOpen((current) => !current)}
className="flex min-h-10 w-full items-center justify-between gap-3 py-2 text-left"
>
<span className="text-[10px] font-black text-[#5a6062]">高级调整</span>
<span className="flex min-w-0 items-center gap-1.5 text-[9px] font-semibold text-[#8b9092]">
<span className="truncate">{advancedSummary}</span>
<ChevronDown
size={13}
strokeWidth={1.8}
className={`shrink-0 transition-transform ${advancedOpen ? 'rotate-180' : ''}`}
/>
</span>
</button>
{advancedOpen ? (
<div id="installation-texture-advanced" className="grid grid-cols-2 gap-2 pb-3">
{ADVANCED_FIELDS.map((item) => (
<div
key={item.field}
className={item.field === 'textureRepeat' ? 'col-span-2' : 'min-w-0'}
>
<UvField
item={item}
value={form[item.field]}
canEdit={canEdit}
onChange={(value) => applyForm({ ...form, [item.field]: value })}
/>
</div>
))}
</div>
) : null}
</div>
</section>
)
}

View File

@ -10,6 +10,7 @@ import type {
NodeRotationState,
ThreeDEngine,
} from '@/lib/3d/engine'
import type { MaterialConfig } from '@/lib/3d/material-manager'
import type {
InstallationDrawingNode,
InstallationPartItem,
@ -59,6 +60,7 @@ const VIEW_LABELS: Record<InstallationWorkbenchView, string> = {
finished: '成品展示',
exploded: '爆炸图',
numbering: '部件编号',
materials: '部件贴图',
steps: '安装步骤',
preview: '安装预览',
}
@ -70,12 +72,18 @@ interface InstallationPreviewStageProps {
drawing: InstallationDrawingNode | null
modelUrl: string | null
previewTool?: InstallationToolItem | null
previewToolMaterial?: MaterialConfig | null
selectedNodeNames: string[]
explosionSelectedNodeNames: string[]
numberingSelectedNodeNames: string[]
completedNodeNames: string[]
highlightedNodeNames: string[]
hiddenNodeNames: string[]
materialAssignments?: Array<{
nodeName: string
material: import('@/lib/3d/material-manager').MaterialConfig
}>
toolMaterialConfigsByToolId?: Record<string, MaterialConfig>
modelNodeNames: string[]
nodePositionOffsets: NodePositionOffset[]
nodeRotationStates: NodeRotationState[]
@ -170,12 +178,15 @@ export function InstallationPreviewStage({
drawing,
modelUrl,
previewTool = null,
previewToolMaterial = null,
selectedNodeNames,
explosionSelectedNodeNames,
numberingSelectedNodeNames,
completedNodeNames,
highlightedNodeNames,
hiddenNodeNames,
materialAssignments = [],
toolMaterialConfigsByToolId = {},
modelNodeNames,
nodePositionOffsets,
nodeRotationStates,
@ -273,12 +284,23 @@ export function InstallationPreviewStage({
top: number
} | null>(null)
const [toolSnapshotCapture, setToolSnapshotCapture] = useState<ModelPreviewCapture | null>(null)
const [toolPreviewNodeNames, setToolPreviewNodeNames] = useState<string[]>([])
const [toolAssetError, setToolAssetError] = useState<{
attachmentKey: string
message: string
} | null>(null)
const isToolPreview = Boolean(previewTool)
const previewModelUrl = previewTool?.modelUrl ?? modelUrl
const toolPreviewMaterialAssignments = useMemo(
() =>
previewToolMaterial
? toolPreviewNodeNames.map((nodeName) => ({
nodeName,
material: previewToolMaterial,
}))
: [],
[previewToolMaterial, toolPreviewNodeNames],
)
const canPreview = Boolean(activeStep && modelUrl)
const viewLabel = VIEW_LABELS[view]
const explosionEditingEnabled = canEdit && view === 'exploded'
@ -407,6 +429,7 @@ export function InstallationPreviewStage({
useInstallationToolAttachments({
engine: previewEngine,
tools: drawingTools,
toolMaterialConfigsByToolId,
hardwareParts,
activeHardwareNodeNames: toolAttachmentNodeNames,
toolTransforms: toolAttachmentTransforms,
@ -534,22 +557,28 @@ export function InstallationPreviewStage({
]
: numberingParts
const numberingSelectedPartNodeNames =
(view === 'exploded' || view === 'numbering') && selectedPart ? getPartNodeNames(selectedPart) : []
(view === 'exploded' || view === 'numbering' || view === 'materials') && selectedPart
? getPartNodeNames(selectedPart)
: []
const activeSelectedNodeNames =
view === 'numbering' && numberingVisibleNodeNames.length > 0
? numberingVisibleNodeNames
: view === 'exploded' && explosionSelectedNodeNames.length > 0
? explosionSelectedNodeNames
: (view === 'exploded' || view === 'numbering') && numberingSelectedNodeNames.length > 0
? numberingSelectedNodeNames
: (view === 'exploded' || view === 'numbering') && numberingSelectedPartNodeNames.length > 0
? numberingSelectedPartNodeNames
: selectedNodeNames.length > 0
? selectedNodeNames
: selectedPart
? getPartNodeNames(selectedPart)
: []
view === 'materials'
? []
: view === 'numbering' && numberingVisibleNodeNames.length > 0
? numberingVisibleNodeNames
: view === 'exploded' && explosionSelectedNodeNames.length > 0
? explosionSelectedNodeNames
: (view === 'exploded' || view === 'numbering') &&
numberingSelectedNodeNames.length > 0
? numberingSelectedNodeNames
: (view === 'exploded' || view === 'numbering') &&
numberingSelectedPartNodeNames.length > 0
? numberingSelectedPartNodeNames
: selectedNodeNames.length > 0
? selectedNodeNames
: selectedPart
? getPartNodeNames(selectedPart)
: []
const resolvedViewportInstanceKey =
viewportInstanceKey ??
@ -600,6 +629,9 @@ export function InstallationPreviewStage({
}
highlightedNodeNames={isToolPreview ? [] : highlightedNodeNames}
hiddenNodeNames={isToolPreview ? [] : hiddenNodeNames}
materialAssignments={
isToolPreview ? toolPreviewMaterialAssignments : materialAssignments
}
nodePositionOffsets={isToolPreview ? [] : nodePositionOffsets}
nodeRotationStates={isToolPreview ? [] : nodeRotationStates}
nodeLocalTransformStates={isToolPreview ? [] : nodeLocalTransformStates}
@ -612,7 +644,9 @@ export function InstallationPreviewStage({
emptyDescription="先在右侧选择 SPU、型号和图纸 ID,再上传对应模型进入安装步骤编辑。"
emptyIcon={<Box size={30} strokeWidth={1.6} />}
emptyBackdrop={<AssetWorkbenchEmptyBackdrop />}
onModelNodesChange={isToolPreview ? () => {} : onModelNodesChange}
onModelNodesChange={
isToolPreview ? setToolPreviewNodeNames : onModelNodesChange
}
onEngineReady={isToolPreview ? undefined : onEngineReady}
onCaptureReady={isToolPreview ? handleToolSnapshotCaptureReady : onCaptureReady}
onNodeSelect={isToolPreview ? () => {} : handleStageNodeSelect}
@ -774,7 +808,9 @@ export function InstallationPreviewStage({
? `当前图纸:${drawing?.code ?? '-'} · 当前页面:${viewLabel} · 点击部件后可直接移动或旋转,爆炸状态会自动保存`
: view === 'numbering'
? `当前图纸:${drawing?.code ?? '-'} · 当前页面:${viewLabel} · 已展示 ${numberingCurrentIndex}/${numberingTotalCount} 个编号组`
: `当前图纸:${drawing?.code ?? '-'} · 当前页面:${viewLabel} · 当前部件 ${partCount} 个`
: view === 'materials'
? `当前图纸:${drawing?.code ?? '-'} · 当前页面:${viewLabel} · 选择板件编号后调整贴图`
: `当前图纸:${drawing?.code ?? '-'} · 当前页面:${viewLabel} · 当前部件 ${partCount} 个`
: '当前图纸还没有模型,先上传 .glb / .gltf / .obj / .zip 后再继续编辑。'}
</span>
</div>

View File

@ -0,0 +1,52 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { InstallationTextureNumericStepper } from './InstallationTextureNumericStepper'
function renderStepper(onChange = jest.fn()) {
render(
<InstallationTextureNumericStepper
id="texture-size"
label="纹理尺寸 mm"
value="600"
placeholder="600"
step={10}
min={10}
max={10000}
onChange={onChange}
/>,
)
return onChange
}
describe('InstallationTextureNumericStepper', () => {
afterEach(() => {
jest.useRealTimers()
})
it('adds one configured step for a normal click', () => {
const onChange = renderStepper()
fireEvent.click(screen.getByRole('button', { name: '增加纹理尺寸 mm' }))
expect(onChange).toHaveBeenCalledWith('610')
})
it('repeats at the controlled rate while the button is held', () => {
jest.useFakeTimers()
const onChange = renderStepper()
const increaseButton = screen.getByRole('button', { name: '增加纹理尺寸 mm' })
fireEvent.pointerDown(increaseButton)
act(() => {
jest.advanceTimersByTime(800)
})
fireEvent.pointerUp(increaseButton)
expect(onChange.mock.calls.map(([value]) => value)).toEqual([
'610',
'620',
'630',
'640',
])
})
})

View File

@ -0,0 +1,126 @@
'use client'
import { useEffect, useRef } from 'react'
import { Minus, Plus } from 'lucide-react'
interface InstallationTextureNumericStepperProps {
id: string
label: string
value: string
placeholder: string
step: number
min?: number
max?: number
disabled?: boolean
onChange: (value: string) => void
}
const HOLD_DELAY_MS = 380
const HOLD_INTERVAL_MS = 140
function decimalPlaces(value: number) {
const text = String(value)
return text.includes('.') ? text.length - text.indexOf('.') - 1 : 0
}
function clamp(value: number, min?: number, max?: number) {
return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, value))
}
export function InstallationTextureNumericStepper({
id,
label,
value,
placeholder,
step,
min,
max,
disabled = false,
onChange,
}: InstallationTextureNumericStepperProps) {
const valueRef = useRef(value)
const holdDelayRef = useRef<number | null>(null)
const holdIntervalRef = useRef<number | null>(null)
useEffect(() => {
valueRef.current = value
}, [value])
const stopHolding = () => {
if (holdDelayRef.current !== null) window.clearTimeout(holdDelayRef.current)
if (holdIntervalRef.current !== null) window.clearInterval(holdIntervalRef.current)
holdDelayRef.current = null
holdIntervalRef.current = null
}
useEffect(() => stopHolding, [])
const changeBy = (direction: -1 | 1) => {
const parsed = Number(valueRef.current.trim())
const fallback = Number(placeholder)
const base = Number.isFinite(parsed) ? parsed : Number.isFinite(fallback) ? fallback : 0
const precision = decimalPlaces(step)
const next = clamp(base + direction * step, min, max)
const formatted = precision > 0 ? next.toFixed(precision) : String(Math.round(next))
valueRef.current = formatted
onChange(formatted)
}
const startHolding = (direction: -1 | 1) => {
if (disabled) return
stopHolding()
holdDelayRef.current = window.setTimeout(() => {
changeBy(direction)
holdIntervalRef.current = window.setInterval(
() => changeBy(direction),
HOLD_INTERVAL_MS,
)
}, HOLD_DELAY_MS)
}
const stepButtonClass =
'flex h-full w-8 shrink-0 items-center justify-center text-[#5a6062] transition hover:bg-black/[0.04] disabled:cursor-not-allowed disabled:opacity-35'
return (
<div className="flex h-8 w-full min-w-0 overflow-hidden rounded-[6px] border border-black/[0.09] bg-white focus-within:border-[#2d3335]">
<button
type="button"
title={`减小${label}`}
aria-label={`减小${label}`}
disabled={disabled}
onPointerDown={() => startHolding(-1)}
onPointerUp={stopHolding}
onPointerCancel={stopHolding}
onPointerLeave={stopHolding}
onClick={() => changeBy(-1)}
className={stepButtonClass}
>
<Minus size={12} strokeWidth={2} />
</button>
<input
id={id}
aria-label={label}
inputMode="decimal"
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-full min-w-0 flex-1 border-x border-black/[0.07] bg-white px-1.5 text-center text-[11px] font-semibold text-[#2d3335] outline-none placeholder:text-[#a1a6a8] disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
title={`增加${label}`}
aria-label={`增加${label}`}
disabled={disabled}
onPointerDown={() => startHolding(1)}
onPointerUp={stopHolding}
onPointerCancel={stopHolding}
onPointerLeave={stopHolding}
onClick={() => changeBy(1)}
className={stepButtonClass}
>
<Plus size={12} strokeWidth={2} />
</button>
</div>
)
}

View File

@ -0,0 +1,49 @@
'use client'
import { useEffect, useState } from 'react'
import type { MaterialCategoryWithMaterials } from '@/lib/3d/material-manager'
interface MaterialsResponse {
success: boolean
data?: MaterialCategoryWithMaterials[]
message?: string
}
export function useInstallationMaterialLibrary() {
const [data, setData] = useState<MaterialCategoryWithMaterials[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const controller = new AbortController()
const load = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch('/api/materials', {
signal: controller.signal,
})
const payload = (await response.json()) as MaterialsResponse
if (!response.ok || !payload.success || !Array.isArray(payload.data)) {
throw new Error(payload.message || '获取材质列表失败')
}
setData(payload.data)
} catch (loadError) {
if (controller.signal.aborted) return
setError(loadError instanceof Error ? loadError : new Error('获取材质列表失败'))
} finally {
if (!controller.signal.aborted) setIsLoading(false)
}
}
void load()
return () => controller.abort()
}, [])
return { data, isLoading, error }
}

View File

@ -0,0 +1,201 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { MaterialConfig } from '@/lib/3d/material-manager'
import type { InstallationPartItem } from '../types'
import {
applyInstallationMaterialToPart,
applyInstallationMaterialToParts,
buildInstallationNodeMaterialAssignments,
buildInstallationToolMaterialConfigsByToolId,
patchInstallationMaterialsForParts,
removeInstallationMaterialFromPart,
removeInstallationMaterialsFromParts,
resolveInstallationPartMaterial,
} from '../lib/installationPartMaterialAssignments'
import {
fetchInstallationNodeMaterialConfig,
saveInstallationNodeMaterialConfig,
} from '../lib/installationNodeMaterialConfigApi'
import {
readStoredInstallationNodeMaterials,
writeStoredInstallationNodeMaterials,
} from '../lib/installationNodeMaterialStorage'
import { isInstallationDemoLocalRuntime } from '../lib/installationDemoRuntimeStorage'
export function useInstallationNodeMaterialConfig(
drawingId: string | null,
canEdit: boolean,
) {
const [configsByDrawingId, setConfigsByDrawingId] = useState<
Record<string, Record<string, MaterialConfig>>
>(readStoredInstallationNodeMaterials)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [dirtyDrawingIds, setDirtyDrawingIds] = useState<Set<string>>(() => new Set())
const loadedDrawingIdsRef = useRef(new Set<string>())
useEffect(() => {
if (!drawingId || loadedDrawingIdsRef.current.has(drawingId)) return
loadedDrawingIdsRef.current.add(drawingId)
if (isInstallationDemoLocalRuntime()) return
let cancelled = false
setLoading(true)
fetchInstallationNodeMaterialConfig(drawingId)
.then((nodeMaterialConfigs) => {
if (cancelled) return
setConfigsByDrawingId((current) => {
const next = { ...current, [drawingId]: nodeMaterialConfigs }
writeStoredInstallationNodeMaterials(next)
return next
})
})
.catch((error) => {
if (!cancelled) {
setMessage(error instanceof Error ? error.message : '部件贴图读取失败')
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [drawingId])
const nodeMaterialConfigs = useMemo(
() => (drawingId ? configsByDrawingId[drawingId] ?? {} : {}),
[configsByDrawingId, drawingId],
)
const materialAssignments = useMemo(
() => buildInstallationNodeMaterialAssignments(nodeMaterialConfigs),
[nodeMaterialConfigs],
)
const toolMaterialConfigsByToolId = useMemo(
() => buildInstallationToolMaterialConfigsByToolId(nodeMaterialConfigs),
[nodeMaterialConfigs],
)
const dirty = Boolean(drawingId && dirtyDrawingIds.has(drawingId))
const replaceDrawingConfigs = useCallback(
(updater: (current: Record<string, MaterialConfig>) => Record<string, MaterialConfig>) => {
if (!drawingId || !canEdit) return
setConfigsByDrawingId((current) => ({
...current,
[drawingId]: updater(current[drawingId] ?? {}),
}))
setDirtyDrawingIds((current) => new Set(current).add(drawingId))
setMessage(null)
},
[canEdit, drawingId],
)
const applyMaterialToPart = useCallback(
(part: InstallationPartItem, material: MaterialConfig) => {
replaceDrawingConfigs((current) =>
applyInstallationMaterialToPart(current, part, material),
)
},
[replaceDrawingConfigs],
)
const patchPartMaterial = useCallback(
(part: InstallationPartItem, patch: Partial<MaterialConfig>) => {
replaceDrawingConfigs((current) => {
const existing = resolveInstallationPartMaterial(part, current)
if (!existing) return current
return applyInstallationMaterialToPart(current, part, { ...existing, ...patch })
})
},
[replaceDrawingConfigs],
)
const applyMaterialToParts = useCallback(
(parts: InstallationPartItem[], material: MaterialConfig) => {
replaceDrawingConfigs((current) =>
applyInstallationMaterialToParts(current, parts, material),
)
},
[replaceDrawingConfigs],
)
const patchMaterialsForParts = useCallback(
(
parts: InstallationPartItem[],
updater: (material: MaterialConfig, part: InstallationPartItem) => MaterialConfig,
) => {
replaceDrawingConfigs((current) =>
patchInstallationMaterialsForParts(current, parts, updater),
)
},
[replaceDrawingConfigs],
)
const clearPartMaterial = useCallback(
(part: InstallationPartItem) => {
replaceDrawingConfigs((current) => removeInstallationMaterialFromPart(current, part))
},
[replaceDrawingConfigs],
)
const clearMaterialsForParts = useCallback(
(parts: InstallationPartItem[]) => {
replaceDrawingConfigs((current) =>
removeInstallationMaterialsFromParts(current, parts),
)
},
[replaceDrawingConfigs],
)
const save = useCallback(async () => {
if (!drawingId || !canEdit || saving) return false
setSaving(true)
setMessage(null)
try {
const savedConfigs = isInstallationDemoLocalRuntime()
? nodeMaterialConfigs
: await saveInstallationNodeMaterialConfig(drawingId, nodeMaterialConfigs)
setConfigsByDrawingId((current) => {
const next = { ...current, [drawingId]: savedConfigs }
writeStoredInstallationNodeMaterials(next)
return next
})
setDirtyDrawingIds((current) => {
const next = new Set(current)
next.delete(drawingId)
return next
})
setMessage(isInstallationDemoLocalRuntime() ? '部件贴图已保存到本机' : '部件贴图已保存')
return true
} catch (error) {
setMessage(error instanceof Error ? error.message : '部件贴图保存失败')
return false
} finally {
setSaving(false)
}
}, [canEdit, drawingId, nodeMaterialConfigs, saving])
return {
nodeMaterialConfigs,
materialAssignments,
toolMaterialConfigsByToolId,
loading,
saving,
dirty,
message,
resolvePartMaterial: (part: InstallationPartItem | null) =>
resolveInstallationPartMaterial(part, nodeMaterialConfigs),
applyMaterialToPart,
applyMaterialToParts,
patchPartMaterial,
patchMaterialsForParts,
clearPartMaterial,
clearMaterialsForParts,
save,
}
}

View File

@ -0,0 +1,70 @@
'use client'
import { useEffect, useMemo } from 'react'
import type { ThreeDEngine } from '@/lib/3d/engine'
import type { InstallationPartItem } from '../types'
import {
resolveNumberingRepresentativeHiddenNodeNames,
resolveRepresentativeNumberingNodeNames,
} from '../lib/installationNumberingRepresentativeNodes'
interface UseInstallationPartTexturePresentationParams {
active: boolean
engine: ThreeDEngine | null
part: InstallationPartItem | null
candidateNodeNames: string[]
}
export function useInstallationPartTexturePresentation({
active,
engine,
part,
candidateNodeNames,
}: UseInstallationPartTexturePresentationParams) {
const visibleNodeNames = useMemo(
() =>
active && part
? resolveRepresentativeNumberingNodeNames({
engine,
part,
})
: [],
[active, engine, part],
)
const hiddenNodeNames = useMemo(
() =>
active
? resolveNumberingRepresentativeHiddenNodeNames({
engine,
candidateNodeNames,
visibleNodeNames,
})
: [],
[active, candidateNodeNames, engine, visibleNodeNames],
)
const visibleNodeSignature = visibleNodeNames.join('\u001f')
useEffect(() => {
if (!active || !engine || visibleNodeNames.length === 0) return
const timer = window.setTimeout(() => {
engine.cancelCameraAnimation()
const framed = engine.fitToNodeNames(visibleNodeNames, 1.16)
if (!framed) engine.fitToView(1.16)
}, 180)
return () => {
window.clearTimeout(timer)
engine.cancelCameraAnimation()
}
}, [active, engine, visibleNodeNames, visibleNodeSignature])
return {
visibleNodeNames,
hiddenNodeNames,
}
}

View File

@ -418,7 +418,7 @@ export function useInstallationPreviewController({
}
return buildInstallationStepOwnEditOffsets(activeStep)
}
if (stageView === 'exploded' || stageView === 'numbering') {
if (stageView === 'exploded' || stageView === 'numbering' || stageView === 'materials') {
return buildExplodedOffsets(parts)
}
if (stageView === 'preview') {
@ -433,7 +433,7 @@ export function useInstallationPreviewController({
}
return buildInstallationStepOwnEditRotations(activeStep)
}
if (stageView === 'exploded' || stageView === 'numbering') {
if (stageView === 'exploded' || stageView === 'numbering' || stageView === 'materials') {
return buildExplodedRotations(parts)
}
if (stageView === 'preview') {
@ -580,7 +580,11 @@ export function useInstallationPreviewController({
: previousRotationMap
const shouldSnapToTarget =
immediate || stageView === 'steps' || stageView === 'exploded' || stageView === 'numbering'
immediate ||
stageView === 'steps' ||
stageView === 'exploded' ||
stageView === 'numbering' ||
stageView === 'materials'
if (shouldSnapToTarget) {
currentOffsetMapRef.current = nextOffsetMap

View File

@ -5,6 +5,10 @@ import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
import type { ThreeDEngine } from '@/lib/3d/engine'
import {
materialManager,
type MaterialConfig,
} from '@/lib/3d/material-manager'
import {
normalizeInstallationToolMotionConfig,
type InstallationToolMotionType,
@ -29,6 +33,7 @@ import {
interface ToolAttachmentRecord {
root: THREE.Group
toolId: string
hardwareObject: THREE.Object3D
hardwareNodeName: string
instanceNodeName: string
@ -43,12 +48,15 @@ interface ToolAttachmentRecord {
toolMotionAxisLocal: THREE.Vector3
toolSpinTurns: number
toolStrikeDistance: number
originalMaterials: Map<string, THREE.Material | THREE.Material[]>
materialRevision: number
unregisterRaycastRoot: () => void
}
interface InstallationToolAttachmentOptions {
engine: ThreeDEngine | null
tools: InstallationToolItem[]
toolMaterialConfigsByToolId?: Record<string, MaterialConfig>
hardwareParts: InstallationPartItem[]
activeHardwareNodeNames: string[]
toolTransforms: Record<string, InstallationToolActionTransform>
@ -88,9 +96,51 @@ function captureFollowState(record: ToolAttachmentRecord) {
record.toolStartWorldRotation = record.root.getWorldQuaternion(new THREE.Quaternion())
}
function captureOriginalMaterials(root: THREE.Object3D) {
const materials = new Map<string, THREE.Material | THREE.Material[]>()
root.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) return
const mesh = child as THREE.Mesh
materials.set(mesh.uuid, mesh.material)
})
return materials
}
function applyToolAttachmentMaterial(
engine: ThreeDEngine,
record: ToolAttachmentRecord,
config: MaterialConfig | undefined,
) {
record.materialRevision += 1
const revision = record.materialRevision
if (!config) {
record.root.traverse((child) => {
if (!(child as THREE.Mesh).isMesh) return
const mesh = child as THREE.Mesh
const original = record.originalMaterials.get(mesh.uuid)
if (!original) return
mesh.material = Array.isArray(original)
? original.map((material) => material.clone())
: original.clone()
})
return
}
engine.applyMaterialToMesh(
record.instanceNodeName,
materialManager.createImmediateMaterial(config),
)
void materialManager.createMaterial(config).then((material) => {
if (record.materialRevision !== revision || !record.root.parent) return
engine.applyMaterialToMesh(record.instanceNodeName, material)
})
}
export function useInstallationToolAttachments({
engine,
tools,
toolMaterialConfigsByToolId = {},
hardwareParts,
activeHardwareNodeNames,
toolTransforms,
@ -104,6 +154,7 @@ export function useInstallationToolAttachments({
const recordsRef = useRef<ToolAttachmentRecord[]>([])
const visibilityRef = useRef({ activeNodeNames: new Set<string>(), visible: false })
const toolTransformsRef = useRef(toolTransforms)
const toolMaterialConfigsRef = useRef(toolMaterialConfigsByToolId)
const followHardwarePathRef = useRef(followHardwarePath)
const screwDriveProgressRef = useRef(screwDriveProgress)
const assetLoadErrorToolIdsRef = useRef(new Set<string>())
@ -161,6 +212,18 @@ export function useInstallationToolAttachments({
screwDriveProgressRef.current = screwDriveProgress
}, [screwDriveProgress])
useEffect(() => {
toolMaterialConfigsRef.current = toolMaterialConfigsByToolId
if (!engine) return
recordsRef.current.forEach((record) => {
applyToolAttachmentMaterial(
engine,
record,
toolMaterialConfigsByToolId[record.toolId],
)
})
}, [engine, toolMaterialConfigsByToolId])
useEffect(() => {
const previousTransforms = toolTransformsRef.current
toolTransformsRef.current = toolTransforms
@ -301,6 +364,7 @@ export function useInstallationToolAttachments({
engine.getScene().add(root)
const record: ToolAttachmentRecord = {
root,
toolId: tool.id,
hardwareObject,
hardwareNodeName: identity.hardwareNodeName,
instanceNodeName,
@ -320,9 +384,16 @@ export function useInstallationToolAttachments({
toolMotionAxisLocal: motionAxisLocal,
toolSpinTurns: motion.spinTurns,
toolStrikeDistance: preparedTool.maxDimension * motion.strikeDistanceRatio,
originalMaterials: captureOriginalMaterials(root),
materialRevision: 0,
unregisterRaycastRoot: engine.registerExternalRaycastRoot(root),
}
recordsRef.current.push(record)
applyToolAttachmentMaterial(
engine,
record,
toolMaterialConfigsRef.current[tool.id],
)
if (
followHardwarePathRef.current &&
visibilityRef.current.activeNodeNames.has(identity.hardwareNodeName)

View File

@ -0,0 +1,46 @@
'use client'
import type { MaterialConfig } from '@/lib/3d/material-manager'
interface NodeMaterialConfigApiResponse {
success?: boolean
data?: {
config?: {
nodeMaterialConfigs?: Record<string, MaterialConfig>
} | null
}
error?: { message?: string }
}
function readError(payload: NodeMaterialConfigApiResponse | null, fallback: string) {
return payload?.error?.message || fallback
}
export async function fetchInstallationNodeMaterialConfig(drawingId: string) {
const response = await fetch(
`/api/installation-demo-workbench/node-material-config?drawingId=${encodeURIComponent(drawingId)}`,
{ credentials: 'same-origin' },
)
const payload = (await response.json().catch(() => null)) as NodeMaterialConfigApiResponse | null
if (!response.ok || !payload?.success) {
throw new Error(readError(payload, '部件贴图读取失败'))
}
return payload.data?.config?.nodeMaterialConfigs ?? {}
}
export async function saveInstallationNodeMaterialConfig(
drawingId: string,
nodeMaterialConfigs: Record<string, MaterialConfig>,
) {
const response = await fetch('/api/installation-demo-workbench/node-material-config', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ drawingId, nodeMaterialConfigs }),
})
const payload = (await response.json().catch(() => null)) as NodeMaterialConfigApiResponse | null
if (!response.ok || !payload?.success || !payload.data?.config) {
throw new Error(readError(payload, '部件贴图保存失败'))
}
return payload.data.config.nodeMaterialConfigs ?? {}
}

View File

@ -0,0 +1,104 @@
import type { MaterialConfig } from '@/lib/3d/material-manager'
import type { InstallationPartItem } from '../types'
import {
applyInstallationMaterialToPart,
applyInstallationMaterialToParts,
buildInstallationNodeMaterialAssignments,
buildInstallationToolMaterialConfigsByToolId,
patchInstallationMaterialsForParts,
removeInstallationMaterialFromPart,
removeInstallationMaterialsFromParts,
resolveInstallationPartMaterial,
} from './installationPartMaterialAssignments'
import { buildInstallationToolMaterialNodeName } from './installationPartTextureTargets'
const material: MaterialConfig = {
id: 'oak',
name: 'Oak',
categoryId: 'wood',
color: '#b88a5a',
metalness: 0,
roughness: 0.65,
opacity: 1,
}
const part = {
id: 'part-1',
nodeName: 'board-a',
nodeNames: ['board-a', 'board-b'],
code: '1',
name: 'Side board',
quantity: 2,
kind: 'part',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-17T00:00:00.000Z',
} satisfies InstallationPartItem
const secondPart = {
...part,
id: 'part-2',
nodeName: 'board-c',
nodeNames: ['board-c'],
code: '2',
} satisfies InstallationPartItem
describe('installation part material assignments', () => {
it('applies one material to every node bound to a numbered part', () => {
const result = applyInstallationMaterialToPart({}, part, material)
expect(result['board-a']).toEqual(material)
expect(result['board-b']).toEqual(material)
expect(resolveInstallationPartMaterial(part, result)).toEqual(material)
})
it('removes only the selected part assignments', () => {
const result = removeInstallationMaterialFromPart(
{
'board-a': material,
'board-b': material,
untouched: { ...material, id: 'other' },
},
part,
)
expect(result['board-a']).toBeUndefined()
expect(result['board-b']).toBeUndefined()
expect(result.untouched?.id).toBe('other')
})
it('returns stable node assignments for both the main and finished previews', () => {
const toolNodeName = buildInstallationToolMaterialNodeName('tool-1')
const result = buildInstallationNodeMaterialAssignments({
'board-b': material,
'board-a': material,
[toolNodeName]: { ...material, id: 'tool-material' },
})
expect(result.map((item) => item.nodeName)).toEqual(['board-a', 'board-b'])
expect(
buildInstallationToolMaterialConfigsByToolId({
'board-a': material,
[toolNodeName]: { ...material, id: 'tool-material' },
}),
).toEqual({ 'tool-1': { ...material, id: 'tool-material' } })
})
it('applies, patches and clears the complete numbered-board scope', () => {
const applied = applyInstallationMaterialToParts({}, [part, secondPart], material)
expect(Object.keys(applied).sort()).toEqual(['board-a', 'board-b', 'board-c'])
const patched = patchInstallationMaterialsForParts(
applied,
[part, secondPart],
(current) => ({ ...current, textureRepeatU: 2 }),
)
expect(patched['board-a']?.textureRepeatU).toBe(2)
expect(patched['board-b']?.textureRepeatU).toBe(2)
expect(patched['board-c']?.textureRepeatU).toBe(2)
expect(removeInstallationMaterialsFromParts(patched, [part, secondPart])).toEqual({})
})
})

View File

@ -0,0 +1,105 @@
import type { MaterialConfig } from '@/lib/3d/material-manager'
import type { InstallationPartItem } from '../types'
import { getPartNodeNames } from './installationPartCatalogHelpers'
import {
isInstallationToolMaterialNodeName,
resolveInstallationToolIdFromMaterialNodeName,
} from './installationPartTextureTargets'
export interface InstallationNodeMaterialAssignment {
nodeName: string
material: MaterialConfig
}
export function buildInstallationNodeMaterialAssignments(
nodeMaterialConfigs: Record<string, MaterialConfig>,
): InstallationNodeMaterialAssignment[] {
return Object.entries(nodeMaterialConfigs)
.filter(
([nodeName]) =>
nodeName.trim().length > 0 && !isInstallationToolMaterialNodeName(nodeName),
)
.sort(([left], [right]) => left.localeCompare(right))
.map(([nodeName, material]) => ({ nodeName, material }))
}
export function buildInstallationToolMaterialConfigsByToolId(
nodeMaterialConfigs: Record<string, MaterialConfig>,
) {
return Object.fromEntries(
Object.entries(nodeMaterialConfigs).flatMap(([nodeName, material]) => {
const toolId = resolveInstallationToolIdFromMaterialNodeName(nodeName)
return toolId ? [[toolId, material] as const] : []
}),
)
}
export function resolveInstallationPartMaterial(
part: InstallationPartItem | null,
nodeMaterialConfigs: Record<string, MaterialConfig>,
): MaterialConfig | null {
if (!part) return null
for (const nodeName of getPartNodeNames(part)) {
const material = nodeMaterialConfigs[nodeName]
if (material) return material
}
return null
}
export function applyInstallationMaterialToPart(
nodeMaterialConfigs: Record<string, MaterialConfig>,
part: InstallationPartItem,
material: MaterialConfig,
): Record<string, MaterialConfig> {
const next = { ...nodeMaterialConfigs }
getPartNodeNames(part).forEach((nodeName) => {
next[nodeName] = { ...material }
})
return next
}
export function applyInstallationMaterialToParts(
nodeMaterialConfigs: Record<string, MaterialConfig>,
parts: InstallationPartItem[],
material: MaterialConfig,
): Record<string, MaterialConfig> {
return parts.reduce(
(current, part) => applyInstallationMaterialToPart(current, part, material),
nodeMaterialConfigs,
)
}
export function patchInstallationMaterialsForParts(
nodeMaterialConfigs: Record<string, MaterialConfig>,
parts: InstallationPartItem[],
updater: (material: MaterialConfig, part: InstallationPartItem) => MaterialConfig,
): Record<string, MaterialConfig> {
return parts.reduce((current, part) => {
const existing = resolveInstallationPartMaterial(part, current)
if (!existing) return current
return applyInstallationMaterialToPart(current, part, updater(existing, part))
}, nodeMaterialConfigs)
}
export function removeInstallationMaterialFromPart(
nodeMaterialConfigs: Record<string, MaterialConfig>,
part: InstallationPartItem,
): Record<string, MaterialConfig> {
const next = { ...nodeMaterialConfigs }
getPartNodeNames(part).forEach((nodeName) => {
delete next[nodeName]
})
return next
}
export function removeInstallationMaterialsFromParts(
nodeMaterialConfigs: Record<string, MaterialConfig>,
parts: InstallationPartItem[],
): Record<string, MaterialConfig> {
return parts.reduce(
(current, part) => removeInstallationMaterialFromPart(current, part),
nodeMaterialConfigs,
)
}

View File

@ -0,0 +1,62 @@
import {
buildInstallationPartTexturePartsByKind,
buildInstallationToolMaterialNodeName,
isInstallationToolMaterialNodeName,
resolveInstallationTextureToolId,
resolveInstallationToolIdFromMaterialNodeName,
} from './installationPartTextureTargets'
import type { InstallationPartItem, InstallationToolItem } from '../types'
const basePart: InstallationPartItem = {
id: 'part-1',
nodeName: 'board-1',
nodeNames: ['board-1'],
code: '1',
name: '侧板',
quantity: 1,
kind: 'part',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: '2026-08-18T00:00:00.000Z',
}
const tool: InstallationToolItem = {
id: 'tool-1',
toolCode: 'T1',
name: '螺丝刀',
modelUrl: '/tool.glb',
fileName: 'tool.glb',
mountOffset: [0, 0, 0],
mountRotation: [0, 0, 0, 1],
motionType: 'spin',
motionPivot: null,
motionAxis: null,
spinTurns: 3,
strikeDistanceRatio: 0.2,
createdAt: '2026-08-18T00:00:00.000Z',
updatedAt: '2026-08-18T00:00:00.000Z',
}
describe('installationPartTextureTargets', () => {
it('keeps boards, hardware and tools in separate texture groups', () => {
const groups = buildInstallationPartTexturePartsByKind(
[basePart, { ...basePart, id: 'hardware-1', code: 'A', kind: 'hardware' }],
[tool],
)
expect(groups.part.map((part) => part.id)).toEqual(['part-1'])
expect(groups.hardware.map((part) => part.id)).toEqual(['hardware-1'])
expect(groups.tool).toHaveLength(1)
expect(groups.tool[0]).toMatchObject({ code: 'T1', name: '螺丝刀', kind: 'tool' })
})
it('round-trips the virtual material target for a tool', () => {
const nodeName = buildInstallationToolMaterialNodeName(tool.id)
const groups = buildInstallationPartTexturePartsByKind([], [tool])
expect(isInstallationToolMaterialNodeName(nodeName)).toBe(true)
expect(resolveInstallationToolIdFromMaterialNodeName(nodeName)).toBe(tool.id)
expect(resolveInstallationTextureToolId(groups.tool[0] ?? null)).toBe(tool.id)
})
})

View File

@ -0,0 +1,66 @@
import type { InstallationPartItem, InstallationPartKind, InstallationToolItem } from '../types'
import { getPartNodeNames } from './installationPartCatalogHelpers'
import { buildInstallationNumberingDisplayParts } from './installationNumberingDisplayParts'
const INSTALLATION_TOOL_MATERIAL_PREFIX = '__installation_tool_material__:'
const INSTALLATION_TOOL_TEXTURE_PART_PREFIX = 'installation-texture-tool:'
export type InstallationPartTexturePartsByKind = Record<
InstallationPartKind,
InstallationPartItem[]
>
export function buildInstallationToolMaterialNodeName(toolId: string) {
return `${INSTALLATION_TOOL_MATERIAL_PREFIX}${toolId}`
}
export function isInstallationToolMaterialNodeName(nodeName: string) {
return nodeName.startsWith(INSTALLATION_TOOL_MATERIAL_PREFIX)
}
export function resolveInstallationToolIdFromMaterialNodeName(nodeName: string) {
if (!isInstallationToolMaterialNodeName(nodeName)) return null
const toolId = nodeName.slice(INSTALLATION_TOOL_MATERIAL_PREFIX.length).trim()
return toolId || null
}
export function buildInstallationToolTexturePart(
tool: InstallationToolItem,
): InstallationPartItem {
const nodeName = buildInstallationToolMaterialNodeName(tool.id)
return {
id: `${INSTALLATION_TOOL_TEXTURE_PART_PREFIX}${tool.id}`,
nodeName,
nodeNames: [nodeName],
code: tool.toolCode,
name: tool.name,
quantity: 1,
kind: 'tool',
offset: [0, 0, 0],
rotation: [0, 0, 0, 1],
nodeTransforms: {},
createdAt: tool.createdAt,
}
}
export function resolveInstallationTextureToolId(part: InstallationPartItem | null) {
if (!part || part.kind !== 'tool') return null
return getPartNodeNames(part)
.map(resolveInstallationToolIdFromMaterialNodeName)
.find((toolId): toolId is string => Boolean(toolId)) ?? null
}
export function buildInstallationPartTexturePartsByKind(
numberingParts: InstallationPartItem[],
tools: InstallationToolItem[],
): InstallationPartTexturePartsByKind {
return {
part: buildInstallationNumberingDisplayParts(numberingParts, 'part').filter(
(part) => getPartNodeNames(part).length > 0,
),
hardware: buildInstallationNumberingDisplayParts(numberingParts, 'hardware').filter(
(part) => getPartNodeNames(part).length > 0,
),
tool: tools.map(buildInstallationToolTexturePart),
}
}

View File

@ -0,0 +1,96 @@
import type { MaterialConfig } from '@/lib/3d/material-manager'
import {
clearInstallationTextureUvFormState,
createInstallationTextureUvFormState,
mergeInstallationTextureUvFormState,
} from './installationPartTextureUvEditor'
const material: MaterialConfig = {
id: 'wood',
name: '木纹',
categoryId: 'wood',
color: '#cccccc',
metalness: 0,
roughness: 0.5,
opacity: 1,
textureRepeat: 1,
textureRepeatU: 1,
textureRepeatV: 2,
textureRotation: Math.PI / 2,
textureSizeMm: 600,
textureAxisConfigs: {
x: { textureRepeatU: 3, textureRepeatV: 4 },
},
}
describe('installation part texture UV editor', () => {
it('reads and writes the complete overall UV configuration', () => {
const form = createInstallationTextureUvFormState(material, '')
expect(form).toEqual({
textureProjectionAxis: '',
textureRepeat: '1',
textureRotation: '90',
textureSizeMm: '600',
textureRepeatU: '1',
textureRepeatV: '2',
})
const next = mergeInstallationTextureUvFormState(material, {
...form,
textureRepeat: '2.5',
textureRotation: '45',
textureSizeMm: '320',
textureRepeatU: '1.5',
textureRepeatV: '0.75',
})
expect(next.textureRepeat).toBe(2.5)
expect(next.textureRotation).toBeCloseTo(Math.PI / 4)
expect(next.textureSizeMm).toBe(320)
expect(next.textureRepeatU).toBe(1.5)
expect(next.textureRepeatV).toBe(0.75)
expect(next.textureAxisConfigs).toEqual(material.textureAxisConfigs)
})
it('keeps independent X/Y/Z settings without overwriting another axis', () => {
const yForm = {
...clearInstallationTextureUvFormState('y' as const),
textureRepeat: '2',
textureRepeatU: '5',
textureRepeatV: '6',
textureRotation: '30',
textureSizeMm: '450',
}
const next = mergeInstallationTextureUvFormState(material, yForm)
expect(next.textureProjectionAxis).toBe('y')
expect(next.textureAxisConfigs?.x).toEqual(material.textureAxisConfigs?.x)
expect(next.textureAxisConfigs?.y).toMatchObject({
textureRepeat: 2,
textureRepeatU: 5,
textureRepeatV: 6,
textureSizeMm: 450,
})
expect(next.textureAxisConfigs?.y?.textureRotation).toBeCloseTo(Math.PI / 6)
})
it('clears only the active axis and preserves the remaining UV configuration', () => {
const withTwoAxes: MaterialConfig = {
...material,
textureProjectionAxis: 'y',
textureAxisConfigs: {
x: { textureRepeat: 2 },
y: { textureRepeat: 4 },
},
}
const next = mergeInstallationTextureUvFormState(
withTwoAxes,
clearInstallationTextureUvFormState('y'),
)
expect(next.textureAxisConfigs).toEqual({ x: { textureRepeat: 2 } })
expect(next.textureProjectionAxis).toBe('x')
expect(next.textureRepeat).toBe(withTwoAxes.textureRepeat)
})
})

View File

@ -0,0 +1,118 @@
import type { AssetVariantUvProjectionAxis } from '@/lib/asset-variant-render-config'
import type { MaterialAxisUvConfig, MaterialConfig } from '@/lib/3d/material-manager'
export type InstallationTextureProjectionAxis = '' | AssetVariantUvProjectionAxis
export interface InstallationTextureUvFormState {
textureProjectionAxis: InstallationTextureProjectionAxis
textureRepeat: string
textureRotation: string
textureSizeMm: string
textureRepeatU: string
textureRepeatV: string
}
function formatNumber(value: number | undefined) {
return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''
}
function formatRotation(value: number | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) return ''
return String(Math.round((value * 180 * 100) / Math.PI) / 100)
}
function parseNumber(value: string) {
const normalized = value.trim()
if (!normalized) return undefined
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : undefined
}
function hasUvValues(config: MaterialAxisUvConfig) {
return (
config.textureRepeat !== undefined ||
config.textureRepeatU !== undefined ||
config.textureRepeatV !== undefined ||
config.textureRotation !== undefined ||
config.textureSizeMm !== undefined
)
}
function parseUvValues(form: InstallationTextureUvFormState): MaterialAxisUvConfig {
const rotationDegrees = parseNumber(form.textureRotation)
return {
textureRepeat: parseNumber(form.textureRepeat),
textureRepeatU: parseNumber(form.textureRepeatU),
textureRepeatV: parseNumber(form.textureRepeatV),
textureRotation:
rotationDegrees === undefined ? undefined : (rotationDegrees * Math.PI) / 180,
textureSizeMm: parseNumber(form.textureSizeMm),
}
}
export function createInstallationTextureUvFormState(
material: MaterialConfig | null,
projectionAxis?: InstallationTextureProjectionAxis,
): InstallationTextureUvFormState {
const activeAxis = projectionAxis ?? material?.textureProjectionAxis ?? ''
const config = activeAxis
? material?.textureAxisConfigs?.[activeAxis] ?? null
: material
return {
textureProjectionAxis: activeAxis,
textureRepeat: formatNumber(config?.textureRepeat),
textureRotation: formatRotation(config?.textureRotation),
textureSizeMm: formatNumber(config?.textureSizeMm),
textureRepeatU: formatNumber(config?.textureRepeatU),
textureRepeatV: formatNumber(config?.textureRepeatV),
}
}
export function clearInstallationTextureUvFormState(
projectionAxis: InstallationTextureProjectionAxis,
): InstallationTextureUvFormState {
return {
textureProjectionAxis: projectionAxis,
textureRepeat: '',
textureRotation: '',
textureSizeMm: '',
textureRepeatU: '',
textureRepeatV: '',
}
}
export function mergeInstallationTextureUvFormState(
material: MaterialConfig,
form: InstallationTextureUvFormState,
): MaterialConfig {
const uvValues = parseUvValues(form)
const activeAxis = form.textureProjectionAxis
if (activeAxis) {
const nextAxisConfigs = { ...(material.textureAxisConfigs ?? {}) }
if (hasUvValues(uvValues)) {
nextAxisConfigs[activeAxis] = uvValues
} else {
delete nextAxisConfigs[activeAxis]
}
const remainingAxes = Object.keys(nextAxisConfigs) as AssetVariantUvProjectionAxis[]
return {
...material,
textureProjectionAxis:
nextAxisConfigs[activeAxis] !== undefined ? activeAxis : remainingAxes[0],
textureAxisConfigs: remainingAxes.length > 0 ? nextAxisConfigs : undefined,
}
}
return {
...material,
textureRepeat: uvValues.textureRepeat,
textureRepeatU: uvValues.textureRepeatU,
textureRepeatV: uvValues.textureRepeatV,
textureRotation: uvValues.textureRotation,
textureSizeMm: uvValues.textureSizeMm,
textureProjectionAxis: undefined,
}
}

View File

@ -151,7 +151,12 @@ export function resolveInstallationViewAnimationDurationMs(
if (stageView === 'preview' && activeStep) {
return resolveInstallationPreviewStepDurationMs(activeStep)
}
if (stageView === 'exploded' || stageView === 'numbering' || stageView === 'steps') {
if (
stageView === 'exploded' ||
stageView === 'numbering' ||
stageView === 'materials' ||
stageView === 'steps'
) {
return 360
}
return 300

View File

@ -13,7 +13,7 @@ export type InstallationModelNode = InstallationDemoModelDTO
export type InstallationDrawingNode = InstallationDemoDrawingDTO
export type InstallationInspectorTab = 'structure' | 'steps' | 'share'
export type InstallationDetailView = 'finished' | 'exploded' | 'numbering' | 'steps'
export type InstallationDetailView = 'finished' | 'exploded' | 'numbering' | 'materials' | 'steps'
export type InstallationWorkbenchView = 'structure' | InstallationDetailView | 'preview'
export type InstallationPartRole = 'fixed' | 'install' | 'hardware'

View File

@ -1,4 +1,5 @@
import type { CameraViewSnapshot } from "@/lib/3d/camera-controller";
import type { MaterialConfig } from "@/lib/3d/material-manager";
import type { InstallationStepMaterialShareSnapshot } from "@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots";
import type {
InstallationDrawingCaptures,
@ -53,6 +54,7 @@ export interface InstallationDemoShareSnapshot {
numberingView: CameraViewSnapshot | null;
numberingSnapshotUrls: Record<string, string>;
numberingToolSnapshotUrls: Record<string, string>;
nodeMaterialConfigs: Record<string, MaterialConfig>;
stepMaterialsByStepId: Record<string, InstallationStepMaterialShareSnapshot>;
steps: InstallationStepDraft[];
drawingDocument: InstallationDemoShareDrawingDocument | null;
@ -75,6 +77,7 @@ export interface CreateInstallationDemoShareInput {
numberingView?: CameraViewSnapshot | null;
numberingSnapshotUrls?: Record<string, string> | null;
numberingToolSnapshotUrls?: Record<string, string> | null;
nodeMaterialConfigs?: Record<string, MaterialConfig> | null;
stepMaterialsByStepId?: Record<
string,
InstallationStepMaterialShareSnapshot

View File

@ -13,6 +13,7 @@ import type {
} from "@/lib/installation-demo-share-types";
import type { InstallationStepMaterialShareSnapshot } from "@/app/installation-demo-workbench/lib/installationStepMaterialShareSnapshots";
import { sanitizeInstallationDrawingTextAnnotations } from "@/app/installation-demo-workbench/lib/installationDrawingTextAnnotations";
import type { MaterialConfig } from "@/lib/3d/material-manager";
import { systemConfigService } from "../system-config.service";
import { installationDemoAccessService } from "./installation-demo-access.service";
@ -114,6 +115,53 @@ function normalizeStringRecord(
);
}
function normalizeNodeMaterialConfigs(
value: unknown,
options: { stored?: boolean } = {},
): Record<string, MaterialConfig> {
if (value === undefined || value === null) return {};
if (!isRecord(value)) {
if (options.stored) return {};
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
"部件贴图配置格式不正确",
);
}
return Object.entries(value).reduce<Record<string, MaterialConfig>>(
(result, [nodeName, rawMaterial]) => {
const normalizedNodeName = nodeName.trim();
if (!normalizedNodeName) return result;
const material = isRecord(rawMaterial) ? rawMaterial : null;
const validMaterial = Boolean(
material &&
typeof material.id === "string" &&
typeof material.name === "string" &&
typeof material.categoryId === "string" &&
typeof material.color === "string" &&
typeof material.metalness === "number" &&
Number.isFinite(material.metalness) &&
typeof material.roughness === "number" &&
Number.isFinite(material.roughness) &&
typeof material.opacity === "number" &&
Number.isFinite(material.opacity),
);
if (!validMaterial) {
if (!options.stored) {
throw new AppError(
ErrorCode.VALIDATION_INVALID_FORMAT,
`部件 ${normalizedNodeName} 的贴图配置不正确`,
);
}
return result;
}
result[normalizedNodeName] = rawMaterial as MaterialConfig;
return result;
},
{},
);
}
function normalizeStepMaterialShareSnapshot(
value: unknown,
): InstallationStepMaterialShareSnapshot {
@ -262,6 +310,7 @@ function normalizeDetailView(
value === "finished" ||
value === "exploded" ||
value === "numbering" ||
value === "materials" ||
value === "steps"
) {
return value;
@ -374,6 +423,9 @@ function normalizeCreateInput(
input.numberingToolSnapshotUrls,
"工具编号快照",
),
nodeMaterialConfigs: normalizeNodeMaterialConfigs(
input.nodeMaterialConfigs,
),
steps: normalizeArray(input.steps, "安装步骤"),
drawingDocument: normalizeDrawingDocument(input.drawingDocument),
stepMaterialsByStepId: normalizeStepMaterialShareSnapshots(
@ -420,6 +472,10 @@ export function normalizeStoredInstallationDemoShareSnapshot(
),
)
: {},
nodeMaterialConfigs: normalizeNodeMaterialConfigs(
value.nodeMaterialConfigs,
{ stored: true },
),
stepMaterialsByStepId: normalizeStepMaterialShareSnapshots(
value.stepMaterialsByStepId,
{
@ -454,6 +510,7 @@ export class InstallationDemoShareService {
numberingView: input.numberingView ?? null,
numberingSnapshotUrls: input.numberingSnapshotUrls ?? {},
numberingToolSnapshotUrls: input.numberingToolSnapshotUrls ?? {},
nodeMaterialConfigs: input.nodeMaterialConfigs ?? {},
stepMaterialsByStepId: input.stepMaterialsByStepId ?? {},
steps: input.steps,
drawingDocument: input.drawingDocument ?? null,