diff --git a/furniture-3d-system/docs/设计规范/黑曜石工坊统一视觉标准.md b/furniture-3d-system/docs/设计规范/黑曜石工坊统一视觉标准.md new file mode 100644 index 00000000..c5d3e699 --- /dev/null +++ b/furniture-3d-system/docs/设计规范/黑曜石工坊统一视觉标准.md @@ -0,0 +1,179 @@ +# Obsidian Atelier 统一视觉标准 + +适用范围:资产中心、辅助装配、草稿组、正式入库、轻仓库等新平台主链页面。 + +目标:统一“黑曜石 / Obsidian Atelier”视觉方向,避免把资产中心做成普通 SaaS 卡片列表,也避免把毛玻璃效果滥用成低可读性的装饰。 + +--- + +## 1. 总体视觉方向 + +Obsidian Atelier 的关键词是:深色精密、克制通透、资产工作台、可检视的工业元件。 + +- `Obsidian(黑曜石)`:整体基调偏深、安静、低干扰,用深灰、炭黑、冷白、金属中性色建立专业感。 +- `Atelier(工坊)`:页面不是营销展示页,而是给人长期处理资产、装配、审核、入库的工作空间。 +- `Unified(统一)`:资产中心、辅助装配、草稿组、正式入库、轻仓库要像同一套产品,不允许每个页面各自发明一套视觉语言。 +- `3D Workbench(3D 工作台)`:新平台的第一感知应是“可操作的资产工作台”,而不是“管理后台卡片墙”。 + +视觉上应保持: + +- 信息层级清楚,主操作区域足够安静。 +- 强调 3D、材质、规格、状态和审核进度,而不是堆叠大面积营销式卡片。 +- 避免一屏全是同色深灰,也避免大面积高饱和渐变。 +- 装饰服从功能,任何质感效果都必须服务于空间层级、焦点和可读性。 + +--- + +## 2. 毛玻璃使用边界 + +毛玻璃是 Obsidian Atelier 中允许且需要的视觉语言,但它只能用于“浮在工作台上、需要轻量透出背景关系”的界面层。 + +允许使用毛玻璃的区域: + +- 顶栏:用于保持全局导航轻盈,同时让 3D 工作台仍是主角。 +- HUD:例如 3D 视角、缩放、选择状态、临时测量信息。 +- 浮动工具条:例如视图切换、选择模式、吸附、材质预览等轻操作。 +- 轻量状态浮层:例如保存中、已同步、当前选中资产摘要、非阻塞提示。 + +不允许使用毛玻璃的区域: + +- 右侧 `Inspector(检视器)` 主工作面板。 +- 数据列表主体。 +- 表格主体。 +- 长文本说明区。 +- 表单密集区。 + +原因很简单:右侧 Inspector 和数据列表承载规格、材质、审核、包装、装配关系等关键数据,必须优先保证可读性、稳定性和可扫描性。这里应使用实体背景、清晰边界和稳定间距,而不是半透明叠层。 + +判断原则: + +- 如果这个区域是“短暂停留的控制层”,可以考虑毛玻璃。 +- 如果这个区域是“长期阅读和编辑的主工作面板”,不要用毛玻璃。 +- 如果背景内容会干扰文字、数字、表格、规格字段,就不要用毛玻璃。 + +--- + +## 3. 元组件概念 + +在新平台里,板材、五金、组件、包装等不应被当作普通卡片处理,而应被当作 `Meta Component(元组件)`。 + +元组件的含义是:它是可以被系统理解、检视、规格化、装配、审核、材质配置和后续入库承接的资产元件。 + +元组件至少应具备这些表达能力: + +- 可检视:能打开 Inspector 查看结构、规格、材质、来源和状态。 +- 可规格化:能承载尺寸、型号、孔位、工艺、包装参数等结构化信息。 +- 可装配:能参与 3D 工作台中的组合、选择、定位和关系确认。 +- 可审核:能进入草稿、审核、正式入库或轻仓库承接链路。 +- 可材质配置:能绑定材质、颜色、纹理、表面工艺和预览效果。 +- 可追踪:能说明来源、版本、更新时间和当前处理状态。 + +因此,元组件的 UI 表达不应只是“图片 + 标题 + 描述”的卡片,而应带有资产身份、规格摘要、状态标记、可检视入口和与 3D 工作台的联动关系。 + +--- + +## 4. 资产中心第一屏 + +资产中心第一屏的默认结构是: + +`左侧 3D 工作台 + 右侧 Inspector` + +这不是普通 SaaS 卡片列表首页。 + +推荐结构: + +- 左侧主区域:3D 工作台,展示当前资产、组件关系、选中状态、材质预览和空间操作。 +- 右侧面板:Inspector,展示选中元组件的规格、材质、装配、审核、包装等关键字段。 +- 顶部区域:轻量全局导航和状态,不抢主工作区注意力。 +- 底部或浮动区域:HUD 和工具条,只放当前 3D 操作需要的轻量控制。 + +列表和卡片可以存在,但应作为: + +- 搜索结果。 +- 资产抽屉。 +- 筛选后的资源面板。 +- 次级视图。 +- 空状态或批量管理场景。 + +它们不应取代第一屏的 3D 工作台和 Inspector 结构。 + +--- + +## 5. CSS / Tailwind 执行口径 + +毛玻璃应克制使用,优先使用透明度、模糊和细边界建立层级,不要叠加厚重阴影、强渐变或大面积发光。 + +推荐毛玻璃基础组合: + +```tsx +className="bg-white/72 backdrop-blur-xl ring-1 ring-black/5 shadow-sm" +``` + +深色场景可用: + +```tsx +className="bg-zinc-950/64 backdrop-blur-2xl ring-1 ring-white/10 shadow-sm" +``` + +顶栏可用: + +```tsx +className="bg-white/72 backdrop-blur-xl ring-1 ring-black/5" +``` + +HUD / 浮动工具条可用: + +```tsx +className="bg-white/68 backdrop-blur-2xl ring-1 ring-white/40 shadow-sm" +``` + +右侧 Inspector 主面板应使用实体背景: + +```tsx +className="bg-white border-l border-zinc-200" +``` + +深色 Inspector 主面板: + +```tsx +className="bg-zinc-950 border-l border-white/10" +``` + +数据列表主体应使用稳定、可读的实体层: + +```tsx +className="bg-white divide-y divide-zinc-100" +``` + +深色数据列表主体: + +```tsx +className="bg-zinc-950 divide-y divide-white/10" +``` + +使用限制: + +- 不要把 `backdrop-blur-xl` 或 `backdrop-blur-2xl` 放进大面积主内容面板。 +- 不要在 Inspector、表格、长列表中使用半透明背景承载正文。 +- 不要为了质感叠加多层 `shadow-xl`、高饱和渐变或大面积发光。 +- 同一屏毛玻璃层级最多保留在顶栏、HUD、浮动工具条和少量状态浮层中。 +- 如果文字可读性下降,以可读性为准,立即改回实体背景。 + +--- + +## 6. 协作验收口径 + +做 Obsidian Atelier 相关页面时,至少按下面几条自查: + +- 第一屏是否仍是工作台体验,而不是 SaaS 卡片墙。 +- 右侧 Inspector 是否清晰、稳定、可长期阅读。 +- 毛玻璃是否只出现在顶栏、HUD、浮动工具条和轻量状态浮层。 +- 板材、五金、组件、包装是否按元组件表达,而不是普通展示卡片。 +- 3D 工作台、Inspector、数据列表之间的主次关系是否明确。 +- 当前页面是否仍符合项目主链:资产中心 -> 辅助装配 -> 草稿组 -> 正式入库 -> 轻仓库。 + +结论口径: + +- 只要毛玻璃进入 Inspector 主工作面板或数据列表主体,默认视为视觉规范不通过。 +- 只要资产中心第一屏退化成普通卡片列表,默认视为产品结构不通过。 +- 只要元组件被当成普通营销卡片处理,默认视为资产表达不通过。 diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx index a7d7f749..7fa676de 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.test.tsx @@ -790,7 +790,7 @@ describe('AssetCenterClient', () => { await actor.clear(screen.getByLabelText('纹理重复')) await actor.type(screen.getByLabelText('纹理重复'), '2') - await actor.click(screen.getByRole('button', { name: '保存 UV' })) + await actor.click(screen.getByRole('button', { name: '确认并同步修改' })) expect(mockedUpdateAssetVariant).toHaveBeenCalledWith( 'variant-board-uv', diff --git a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx index 30d00c37..bb02f314 100644 --- a/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx +++ b/furniture-3d-system/src/app/asset-center/AssetCenterClient.tsx @@ -6,6 +6,7 @@ import { ArrowRight, ChevronDown, ChevronRight, + ChevronUp, FileUp, Layers3, Link2, @@ -13,6 +14,7 @@ import { Pencil, Plus, Search, + Settings2, Shapes, SwatchBook, Trash2, @@ -34,8 +36,8 @@ import { } from '@/components/ui/ContextMenu' import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog' import { useToast, ToastContainer } from '@/components/ui/Toast' +import { AssetWorkbenchEmptyBackdrop } from '@/components/platform/AssetWorkbenchEmptyBackdrop' import { ModelPreviewPanel } from '@/components/platform/ModelPreviewPanel' -import { PlatformSplitLayout } from '@/components/platform/PlatformSplitLayout' import type { PlatformMetricItem } from '@/components/platform/types' import { applyAssetVariantUvConfigToMaterial, @@ -621,28 +623,28 @@ function canReviewAssets(role: string): boolean { } const compactRowActionClassName = - 'inline-flex h-7 items-center rounded-full border border-apple-separator/60 bg-apple-bg-secondary/70 px-2 text-[11px] text-apple-label-secondary transition hover:border-apple-separator hover:bg-apple-bg-secondary hover:text-apple-label-primary disabled:cursor-not-allowed disabled:opacity-50' + 'inline-flex h-7 items-center rounded-[4px] border border-black/[0.04] bg-black/[0.03] px-2 text-[11px] text-[#5a6062] transition hover:border-black/10 hover:bg-black/[0.05] hover:text-[#2d3335] disabled:cursor-not-allowed disabled:opacity-50' const compactRowPrimaryActionClassName = - 'inline-flex h-7 items-center rounded-full border border-apple-blue/15 bg-apple-blue/8 px-2 text-[11px] text-apple-blue transition hover:bg-apple-blue/12 disabled:cursor-not-allowed disabled:opacity-50' + 'inline-flex h-7 items-center rounded-[4px] border border-[#2d3335]/10 bg-[#2d3335] px-2 text-[11px] text-white transition hover:bg-[#384042] disabled:cursor-not-allowed disabled:opacity-50' const compactRowDangerActionClassName = - 'inline-flex h-7 items-center rounded-full border border-apple-red/15 bg-apple-red/5 px-2 text-[11px] text-apple-red transition hover:bg-apple-red/10 disabled:cursor-not-allowed disabled:opacity-50' + 'inline-flex h-7 items-center rounded-[4px] border border-red-500/10 bg-red-500/5 px-2 text-[11px] text-red-600 transition hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-50' const compactRowNeutralStatusClassName = - 'inline-flex h-7 shrink-0 items-center rounded-full border border-apple-separator/60 bg-apple-bg-secondary/70 px-2 text-[11px] text-apple-label-secondary' + 'inline-flex h-7 shrink-0 items-center rounded-[4px] border border-black/[0.04] bg-black/[0.03] px-2 text-[11px] text-[#5a6062]' const compactRowSuccessStatusClassName = - 'inline-flex h-7 shrink-0 items-center rounded-full border border-apple-green/15 bg-apple-green/8 px-2 text-[11px] text-apple-green' + 'inline-flex h-7 shrink-0 items-center rounded-[4px] border border-[#47646b]/15 bg-[#47646b]/[0.07] px-2 text-[11px] font-bold text-[#47646b]' const compactRowWarningStatusClassName = - 'inline-flex h-7 shrink-0 items-center rounded-full border border-apple-orange/15 bg-apple-orange/5 px-2 text-[11px] text-apple-orange' + 'inline-flex h-7 shrink-0 items-center rounded-[4px] border border-amber-500/10 bg-amber-500/5 px-2 text-[11px] text-amber-700' const compactRowDangerStatusClassName = - 'inline-flex h-7 shrink-0 items-center rounded-full border border-apple-red/15 bg-apple-red/5 px-2 text-[11px] text-apple-red' + 'inline-flex h-7 shrink-0 items-center rounded-[4px] border border-red-500/10 bg-red-500/5 px-2 text-[11px] text-red-600' const compactRowIconActionClassName = - 'inline-flex h-7 w-7 items-center justify-center rounded-full border border-apple-separator/40 bg-apple-bg-secondary/60 text-apple-label-secondary transition hover:border-apple-separator hover:bg-apple-bg-secondary hover:text-apple-label-primary' + 'inline-flex h-7 w-7 items-center justify-center rounded-[4px] border border-black/[0.04] bg-black/[0.03] text-[#5a6062] transition hover:border-black/10 hover:bg-black/[0.05] hover:text-[#2d3335]' type ReviewDialogState = { targetType: 'family' | 'variant' @@ -707,6 +709,8 @@ export default function AssetCenterClient({ }) const [familyRoleDefinitions, setFamilyRoleDefinitions] = useState([]) const [editingFamilyId, setEditingFamilyId] = useState(null) + const [selectedInspectorExpanded, setSelectedInspectorExpanded] = useState(false) + const [materialInspectorExpanded, setMaterialInspectorExpanded] = useState(false) const [roleEditorOpen, setRoleEditorOpen] = useState(false) const [roleEditorMode, setRoleEditorMode] = useState<'create' | 'edit'>('create') const [editingRoleValue, setEditingRoleValue] = useState(null) @@ -837,6 +841,7 @@ export default function AssetCenterClient({ JSON.stringify(normalizeAssetVariantUvConfig(selectedVariant?.uvConfig ?? null)), [draftVariantUvConfig, selectedVariant?.uvConfig], ) + const hasPendingVariantSync = Boolean(selectedVariant && variantUvConfigDirty) const previewMaterialAssignments = useMemo(() => { if (!selectedFinishMaterialConfig || previewNodeNames.length === 0) return [] return previewNodeNames.map((nodeName) => ({ @@ -878,6 +883,11 @@ export default function AssetCenterClient({ setVariantUvEditorForm(savedVariantUvEditorForm) }, [savedVariantUvEditorForm]) + useEffect(() => { + setSelectedInspectorExpanded(false) + setMaterialInspectorExpanded(false) + }, [selectedVariantId]) + const variantsByFamilyId = useMemo(() => { const map = new Map() variants.forEach((variant) => { @@ -1355,6 +1365,14 @@ export default function AssetCenterClient({ // ---- 规格款弹窗操作 ---- + function openCreateVariantModal(family: AssetFamilyDTO) { + setVariantModalMode('create') + setEditingVariantId(null) + setVariantForm(emptyVariantForm(family.id)) + setExpandedFamilyIds((prev) => new Set(prev).add(family.id)) + setVariantModalOpen(true) + } + function openDuplicateVariantModal(variant: AssetVariantDTO) { const dims = variant.dimensions ?? {} const mp = variant.mountPoints?.[0] @@ -2271,90 +2289,115 @@ export default function AssetCenterClient({ return ( <> - +
+ } + className="h-full w-full" + showFpsBadge={false} + titleAside={ + selectedVariant ? ( + + ) : null + } + showCameraActionButtons + defaultShowSceneGrid + enableTranslateGizmo + gizmoMode={gizmoMode} + onGizmoModeChange={setGizmoMode} + selectedNodeName={selectedPreviewNodeName} + selectableNodeNames={previewNodeNames} + nodePositionOffsets={previewNodeOffsets} + nodeRotationStates={previewNodeRotations} + materialAssignments={previewMaterialAssignments} + onNodePositionOffsetChange={handlePreviewNodePositionOffsetChange} + onNodeRotationChange={handlePreviewNodeRotationChange} + onGizmoInteractionStart={handlePreviewTransformStart} + onGizmoInteractionEnd={handlePreviewTransformEnd} + onModelNodesChange={(nodeNames) => { + setPreviewNodeNames(nodeNames) + setPreviewNodeOffsetMap((current) => { + const validNodeNameSet = new Set(nodeNames) + const nextEntries = Object.entries(current).filter(([nodeName]) => + validNodeNameSet.has(nodeName), + ) + if (nextEntries.length === Object.keys(current).length) { + return current + } + return Object.fromEntries(nextEntries) + }) + setPreviewNodeRotationMap((current) => { + const validNodeNameSet = new Set(nodeNames) + const nextEntries = Object.entries(current).filter(([nodeName]) => + validNodeNameSet.has(nodeName), + ) + if (nextEntries.length === Object.keys(current).length) { + return current + } + return Object.fromEntries(nextEntries) + }) + setSelectedPreviewNodeName((current) => + current && nodeNames.includes(current) ? current : nodeNames[0] ?? null, + ) + }} + onNodeSelect={(payload) => { + setSelectedPreviewNodeName(payload.nodeName ?? payload.rawNodeName ?? null) + }} + onNodeContextMenu={(payload) => { + setSelectedPreviewNodeName(payload.nodeName ?? payload.rawNodeName ?? selectedPreviewNodeName) + setPreviewContextMenuState({ + visible: true, + x: payload.clientX, + y: payload.clientY, + nodeName: payload.nodeName ?? payload.rawNodeName ?? selectedPreviewNodeName, + }) + setActivePreviewSubmenu(null) + setActiveFinishCategoryId(null) + }} + /> +
+

+ WORKSPACE +

+

+ 左键拖拽旋转 · 滚轮缩放 · 中键平移 · 右键快捷修改 +

+
+
- leftContent={ - { - void handleRegenerateVariantThumbnail() - }} - disabled={submitting} - > - 重生成缩略图 - - ) : null - } - showCameraActionButtons - defaultShowSceneGrid - enableTranslateGizmo - gizmoMode={gizmoMode} - onGizmoModeChange={setGizmoMode} - selectedNodeName={selectedPreviewNodeName} - selectableNodeNames={previewNodeNames} - nodePositionOffsets={previewNodeOffsets} - nodeRotationStates={previewNodeRotations} - materialAssignments={previewMaterialAssignments} - onNodePositionOffsetChange={handlePreviewNodePositionOffsetChange} - onNodeRotationChange={handlePreviewNodeRotationChange} - onGizmoInteractionStart={handlePreviewTransformStart} - onGizmoInteractionEnd={handlePreviewTransformEnd} - onModelNodesChange={(nodeNames) => { - setPreviewNodeNames(nodeNames) - setPreviewNodeOffsetMap((current) => { - const validNodeNameSet = new Set(nodeNames) - const nextEntries = Object.entries(current).filter(([nodeName]) => - validNodeNameSet.has(nodeName), - ) - if (nextEntries.length === Object.keys(current).length) { - return current - } - return Object.fromEntries(nextEntries) - }) - setPreviewNodeRotationMap((current) => { - const validNodeNameSet = new Set(nodeNames) - const nextEntries = Object.entries(current).filter(([nodeName]) => - validNodeNameSet.has(nodeName), - ) - if (nextEntries.length === Object.keys(current).length) { - return current - } - return Object.fromEntries(nextEntries) - }) - setSelectedPreviewNodeName((current) => - current && nodeNames.includes(current) ? current : nodeNames[0] ?? null, - ) - }} - onNodeSelect={(payload) => { - setSelectedPreviewNodeName(payload.nodeName ?? payload.rawNodeName ?? null) - }} - onNodeContextMenu={(payload) => { - setSelectedPreviewNodeName(payload.nodeName ?? payload.rawNodeName ?? selectedPreviewNodeName) - setPreviewContextMenuState({ - visible: true, - x: payload.clientX, - y: payload.clientY, - nodeName: payload.nodeName ?? payload.rawNodeName ?? selectedPreviewNodeName, - }) - setActivePreviewSubmenu(null) - setActiveFinishCategoryId(null) - }} - /> - } - rightContent={ - <> + + {previewContextMenuState.visible && selectedVariant && ( - {/* 规格款弹窗 —— 放在 PlatformSplitLayout 外部,避免被父级 overflow/transform 裁切 */} + {/* 规格款弹窗 —— 放在资产中心工作台外部,避免被父级 overflow/transform 裁切 */} = { Y: new THREE.Vector3(0, 1, 0), Z: new THREE.Vector3(0, 0, 1), }; - function normalizeAngleRadians(value: number): number { let normalized = value; while (normalized <= -Math.PI) { @@ -375,6 +376,24 @@ function areStringRecordsEqual( return true; } +function areNumberRecordsEqual( + left: ModelExpectedDimensions | null | undefined, + right: ModelExpectedDimensions | null | undefined, +): boolean { + if (left === right) return true; + if (!left || !right) return !left && !right; + + const leftKeys = Object.keys(left).filter((key) => left[key] !== undefined); + const rightKeys = Object.keys(right).filter((key) => right[key] !== undefined); + if (leftKeys.length !== rightKeys.length) return false; + + for (const key of leftKeys) { + if (left[key] !== right[key]) return false; + } + + return true; +} + function areHoleArraysEqual( left: HoleOverlayData[] = [], right: HoleOverlayData[] = [], @@ -407,6 +426,7 @@ function getErrorMessage(error: Error): string { function ModelViewerComponent({ modelUrl = "", + modelDimensions = null, worldNodes, worldFrameMode = "always", className = "", @@ -449,12 +469,15 @@ function ModelViewerComponent({ const engineRef = useRef(null); const transformControlsRef = useRef(null); const transformHelperRef = useRef(null); + const fallbackTransformNodeNameRef = useRef(null); const pointerStartRef = useRef< { x: number; y: number; button: number } | null >(null); const explodedProgressRef = useRef(explodedProgress); const hiddenNodeNamesRef = useRef(hiddenNodeNames); const showSceneGridRef = useRef(showSceneGrid); + const modelDimensionsRef = + useRef(modelDimensions); const clearColorRef = useRef(clearColor); const clearAlphaRef = useRef(clearAlpha); const nodePositionOffsetsRef = useRef(nodePositionOffsets); @@ -527,6 +550,10 @@ function ModelViewerComponent({ .join("||") ?? "", [worldNodes], ); + const modelDimensionsSignature = useMemo( + () => JSON.stringify(modelDimensions ?? null), + [modelDimensions], + ); useEffect(() => { explodedProgressRef.current = explodedProgress; @@ -734,7 +761,16 @@ function ModelViewerComponent({ isFreeRotateModifierPressedRef.current = false; appliedMaterialSignatureRef.current.clear(); hideTransformFeedback(); - }, [hideTransformFeedback, modelUrl, worldNodeIdentitySignature]); + }, [ + hideTransformFeedback, + modelDimensionsSignature, + modelUrl, + worldNodeIdentitySignature, + ]); + + useEffect(() => { + modelDimensionsRef.current = modelDimensions; + }, [modelDimensions]); const buildTransformFeedbackLabels = useCallback( (nodeName: string): TransformFeedbackLabel[] => { @@ -914,7 +950,9 @@ function ModelViewerComponent({ if (!normalizedModelUrl) { throw new Error("模型地址无效,无法加载 3D 模型"); } - return engine.loadModel(normalizedModelUrl); + return engine.loadModel(normalizedModelUrl, { + expectedDimensions: modelDimensionsRef.current, + }); })(); await Promise.race([ @@ -997,6 +1035,7 @@ function ModelViewerComponent({ applyNodeRotationStates, captureOriginalNodeRotations, defaultExploded, + modelDimensionsSignature, modelUrl, worldFrameMode, ]); @@ -1132,8 +1171,17 @@ function ModelViewerComponent({ ); const transformHelper = transformControls.getHelper(); transformControls.setSpace("world"); - transformControls.setSize(0.6); + transformControls.setSize(0.72); transformHelper.visible = false; + const longGuideHelpers: THREE.Object3D[] = []; + transformHelper.traverse((obj) => { + if ((obj as THREE.Object3D & { tag?: string }).tag === "helper") { + longGuideHelpers.push(obj); + } + }); + longGuideHelpers.forEach((obj) => { + obj.parent?.remove(obj); + }); // ── Precision Atelier gizmo 样式 ── // 低饱和色:X 珊瑚、Y 鼠尾草、Z 石板蓝 @@ -1151,15 +1199,22 @@ function ModelViewerComponent({ } function applyGizmoStyle() { transformHelper.traverse((obj) => { + const isGuideHelper = + (obj as THREE.Object3D & { tag?: string }).tag === "helper"; + obj.renderOrder = 1000; + if (isGuideHelper) { + obj.visible = false; + } if (obj instanceof THREE.Mesh) { - obj.scale.setScalar(0.7); const mat = obj.material as THREE.MeshBasicMaterial; if (mat?.isMeshBasicMaterial) { const axis = classifyAxis(mat.color); if (axis && AXIS_PALETTE[axis]) mat.color.copy(AXIS_PALETTE[axis]); mat.transparent = true; - mat.opacity = 0.8; + mat.opacity = isGuideHelper ? 0 : 0.92; mat.depthTest = false; + mat.depthWrite = false; + mat.toneMapped = false; } } if ((obj instanceof THREE.Line || obj instanceof THREE.LineSegments)) { @@ -1168,8 +1223,10 @@ function ModelViewerComponent({ const axis = classifyAxis(mat.color); if (axis && AXIS_PALETTE[axis]) mat.color.copy(AXIS_PALETTE[axis]); mat.transparent = true; - mat.opacity = 0.45; + mat.opacity = isGuideHelper ? 0 : 0.82; mat.depthTest = false; + mat.depthWrite = false; + mat.toneMapped = false; } } }); @@ -1215,7 +1272,8 @@ function ModelViewerComponent({ applyRotationSnap(); // hover 时重绘样式(Three.js 会覆盖 material color) - const resolveActiveNodeName = () => selectedNodeNameRef.current; + const resolveActiveNodeName = () => + selectedNodeNameRef.current ?? fallbackTransformNodeNameRef.current; const beginInteraction = () => { const activeNodeName = resolveActiveNodeName(); const engine = engineRef.current; @@ -1355,27 +1413,43 @@ function ModelViewerComponent({ syncRotationSnapRef.current?.(); applyGizmoStyleRef.current?.(); - if (!enableTranslateGizmoRef.current || !selectedNodeNameRef.current) { + if (!enableTranslateGizmoRef.current) { + fallbackTransformNodeNameRef.current = null; currentTransformControls.detach(); currentHelper.visible = false; transformInteractionActiveRef.current = false; return; } - const targetObject = currentEngine.resolveObjectByNodeName( - selectedNodeNameRef.current, - ); + let activeNodeName = selectedNodeNameRef.current; + let targetObject = currentEngine.resolveObjectByNodeName(activeNodeName); if (!targetObject) { + const fallbackTarget = currentEngine.resolveDefaultTransformTarget?.(); + activeNodeName = fallbackTarget?.nodeName ?? null; + targetObject = fallbackTarget?.object ?? null; + } + + if (!targetObject) { + fallbackTransformNodeNameRef.current = null; currentTransformControls.detach(); currentHelper.visible = false; transformInteractionActiveRef.current = false; return; } + fallbackTransformNodeNameRef.current = + activeNodeName === selectedNodeNameRef.current ? null : activeNodeName; + currentTransformControls.enabled = true; + currentTransformControls.showX = true; + currentTransformControls.showY = true; + currentTransformControls.showZ = true; + targetObject.updateMatrixWorld(true); if (currentTransformControls.object?.uuid !== targetObject.uuid) { currentTransformControls.attach(targetObject); } currentHelper.visible = true; + currentHelper.updateMatrixWorld(true); + applyGizmoStyleRef.current?.(); }; transformControls.addEventListener("mouseDown", handleMouseDown); @@ -1412,7 +1486,7 @@ function ModelViewerComponent({ useEffect(() => { syncTransformControlsRef.current?.(); - if (!isWorldMode || !selectedNodeName) { + if (!selectedNodeName) { return } @@ -1423,7 +1497,7 @@ function ModelViewerComponent({ return () => { window.cancelAnimationFrame(animationFrameId) } - }, [enableTranslateGizmo, engineInstance, gizmoMode, isWorldMode, selectedNodeName, worldNodeIdentitySignature]); + }, [enableTranslateGizmo, engineInstance, gizmoMode, selectedNodeName, worldNodeIdentitySignature]); useEffect(() => { if (transformInteractionActiveRef.current) { @@ -1937,41 +2011,65 @@ function ModelViewerComponent({ )} {isLoading && ( -
-
-
-

- 加载 3D 模型中... +

+
+
+ + MODEL STREAM + + + SYNC + +
+
+
+
+

+ 3D 模型流正在建立 +

+

+ 正在读取模型文件与场景网格

)} {error && ( -
-
-
- +
+
+
+
+

+ MODEL INTEGRITY +

+

+ 3D 模型加载失败 +

+
+
+ +
-

- 3D 模型加载失败 -

-

+

{error}

-
- {retryCount > 0 && ( - - 已重试 {retryCount} 次 + + RETRY {retryCount} )}
-

- 如果问题持续存在,请检查模型文件或联系技术支持 +

+ 请核对模型文件是否已同步到本地资源目录,或切换到已有模型的规格款。

@@ -1985,6 +2083,7 @@ export const ModelViewer = memo( (prevProps, nextProps) => { return ( prevProps.modelUrl === nextProps.modelUrl && + areNumberRecordsEqual(prevProps.modelDimensions, nextProps.modelDimensions) && areWorldNodesEqual(prevProps.worldNodes, nextProps.worldNodes) && prevProps.worldFrameMode === nextProps.worldFrameMode && prevProps.className === nextProps.className && diff --git a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx index 7e0dd058..bfda0dae 100644 --- a/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx +++ b/furniture-3d-system/src/components/3d/__tests__/ModelViewer.gizmo.test.tsx @@ -22,6 +22,13 @@ const applyMaterialToMeshMock = jest.fn(); const pickPartNameFromScreenMock = jest.fn(() => null); const rememberSelectionAliasMock = jest.fn(); const targetObject = new THREE.Object3D(); +const resolveObjectByNodeNameMock = jest.fn((nodeName: string | null) => + nodeName ? targetObject : null, +); +const resolveDefaultTransformTargetMock = jest.fn(() => ({ + nodeName: "panel-a", + object: targetObject, +})); const sceneAddMock = jest.fn(); const sceneRemoveMock = jest.fn(); const orbitControls = { enabled: true }; @@ -113,12 +120,13 @@ jest.mock("@/lib/3d/engine", () => ({ add: sceneAddMock, remove: sceneRemoveMock, }), - resolveObjectByNodeName: () => targetObject, + resolveObjectByNodeName: resolveObjectByNodeNameMock, getNodeWorldOffset: getNodeWorldOffsetMock, getNodeLocalRotation: getNodeLocalRotationMock, applyMaterialToMesh: applyMaterialToMeshMock, pickPartNameFromScreen: pickPartNameFromScreenMock, rememberSelectionAlias: rememberSelectionAliasMock, + resolveDefaultTransformTarget: resolveDefaultTransformTargetMock, })), })); @@ -157,6 +165,8 @@ describe("ModelViewer gizmo mode switching", () => { applyMaterialToMeshMock.mockClear(); pickPartNameFromScreenMock.mockClear(); rememberSelectionAliasMock.mockClear(); + resolveObjectByNodeNameMock.mockClear(); + resolveDefaultTransformTargetMock.mockClear(); sceneAddMock.mockClear(); sceneRemoveMock.mockClear(); orbitControls.enabled = true; @@ -275,6 +285,24 @@ describe("ModelViewer gizmo mode switching", () => { ); }); + it("没有显式节点名时,会回退到模型默认对象显示 gizmo", async () => { + render( + , + ); + + await flushViewer(); + + expect(resolveDefaultTransformTargetMock).toHaveBeenCalled(); + expect(transformControlsInstances).toHaveLength(1); + expect(transformControlsInstances[0]?.attach).toHaveBeenCalledWith(targetObject); + expect(transformControlsInstances[0]?.helper.visible).toBe(true); + }); + it("移动时只显示有变化的轴,并显示 mm 数值", async () => { render( { expect(loadModelMock).toHaveBeenCalledTimes(1); }); + + it("加载模型时会把资产业务尺寸传给 3D 引擎用于单位归一", async () => { + render( + , + ); + + await act(async () => { + jest.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + expect(loadModelMock).toHaveBeenCalledWith("/demo.glb", { + expectedDimensions: { length: 1200, width: 600, thickness: 25 }, + }); + }); }); diff --git a/furniture-3d-system/src/components/layout/AppShell.tsx b/furniture-3d-system/src/components/layout/AppShell.tsx index ac8c212f..a31012c1 100644 --- a/furniture-3d-system/src/components/layout/AppShell.tsx +++ b/furniture-3d-system/src/components/layout/AppShell.tsx @@ -39,11 +39,14 @@ interface AppShellProps { export function AppShell({ children, user }: AppShellProps) { const pathname = usePathname() + const isAssetCenterRoute = + pathname === '/asset-center' || pathname.startsWith('/asset-center/') const isAssistedAssemblyRoute = pathname === '/assisted-assembly' || pathname.startsWith('/assisted-assembly/') const isIntakeRoute = pathname === '/intake' || pathname.startsWith('/intake/') const isWarehouseRoute = pathname === '/warehouse' || pathname.startsWith('/warehouse/') - const shouldLockMainViewport = isAssistedAssemblyRoute || isIntakeRoute || isWarehouseRoute + const shouldLockMainViewport = + isAssetCenterRoute || isAssistedAssemblyRoute || isIntakeRoute || isWarehouseRoute useEffect(() => { if (!shouldLockMainViewport || typeof document === 'undefined') { @@ -76,11 +79,13 @@ export function AppShell({ children, user }: AppShellProps) { return (
- - + + {!isAssetCenterRoute && }
+ + +
+

{WORKBENCH_BRAND_NAME}

+

资产中心

+
+ + +
+ +
+ {WORKBENCH_NAV_ITEMS.map((item) => { + const Icon = item.icon + const active = item.href === '/' ? pathname === '/' : pathname.startsWith(item.href) + + return ( + + + {item.label} + + ) + })} +
+ +
+ + + +
+ + ) + } + return (
) diff --git a/furniture-3d-system/src/components/platform/AssetWorkbenchEmptyBackdrop.tsx b/furniture-3d-system/src/components/platform/AssetWorkbenchEmptyBackdrop.tsx new file mode 100644 index 00000000..4dbd9703 --- /dev/null +++ b/furniture-3d-system/src/components/platform/AssetWorkbenchEmptyBackdrop.tsx @@ -0,0 +1,137 @@ +import { cn } from '@/lib/utils' + +interface AssetWorkbenchEmptyBackdropProps { + className?: string +} + +const componentPlates = [ + 'left-[18%] top-[28%] h-[86px] w-[156px] -rotate-6', + 'right-[18%] top-[24%] h-[70px] w-[126px] rotate-3', + 'left-[30%] bottom-[20%] h-[78px] w-[138px] rotate-2', + 'right-[28%] bottom-[27%] h-[56px] w-[104px] -rotate-3', +] + +const hardwareNodes = [ + 'left-[23%] top-[34%]', + 'left-[39%] top-[42%]', + 'right-[24%] top-[31%]', + 'left-[34%] bottom-[27%]', + 'right-[33%] bottom-[36%]', + 'right-[20%] bottom-[25%]', +] + +const relationLines = [ + 'left-[28%] top-[39%] w-[18%] rotate-[13deg]', + 'left-[45%] top-[43%] w-[22%] -rotate-[11deg]', + 'left-[38%] bottom-[34%] w-[25%] rotate-[7deg]', +] + +export function AssetWorkbenchEmptyBackdrop({ className }: AssetWorkbenchEmptyBackdropProps) { + return ( +