feat(installation-demo): add part material editing workflow
This commit is contained in:
parent
75f7b3ac7f
commit
ec5875367a
@ -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}
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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']])
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 ?? {}
|
||||
}
|
||||
@ -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({})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user