Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-mainline

This commit is contained in:
WuYongJie 2026-04-25 18:45:53 +08:00
commit 925dfc8ad5
22 changed files with 1724 additions and 489 deletions

View File

@ -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 主工作面板或数据列表主体,默认视为视觉规范不通过。
- 只要资产中心第一屏退化成普通卡片列表,默认视为产品结构不通过。
- 只要元组件被当成普通营销卡片处理,默认视为资产表达不通过。

View File

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

View File

@ -12,6 +12,7 @@ import {
ThreeDEngine,
type EngineWorldFrameMode,
type EngineWorldNode,
type ModelExpectedDimensions,
type NodePositionOffset,
type NodeRotationState,
} from "@/lib/3d/engine";
@ -29,6 +30,7 @@ import {
interface ModelViewerProps {
modelUrl?: string;
modelDimensions?: ModelExpectedDimensions | null;
worldNodes?: EngineWorldNode[];
worldFrameMode?: EngineWorldFrameMode;
className?: string;
@ -128,7 +130,6 @@ const TRANSFORM_FEEDBACK_WORLD_AXIS: Record<AxisKey, THREE.Vector3> = {
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<ThreeDEngine | null>(null);
const transformControlsRef = useRef<TransformControls | null>(null);
const transformHelperRef = useRef<THREE.Object3D | null>(null);
const fallbackTransformNodeNameRef = useRef<string | null>(null);
const pointerStartRef = useRef<
{ x: number; y: number; button: number } | null
>(null);
const explodedProgressRef = useRef(explodedProgress);
const hiddenNodeNamesRef = useRef<string[]>(hiddenNodeNames);
const showSceneGridRef = useRef(showSceneGrid);
const modelDimensionsRef =
useRef<ModelExpectedDimensions | null>(modelDimensions);
const clearColorRef = useRef(clearColor);
const clearAlphaRef = useRef(clearAlpha);
const nodePositionOffsetsRef = useRef<NodePositionOffset[]>(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 && (
<div className="absolute inset-0 flex items-center justify-center bg-apple-bg-tertiary/85 backdrop-blur-xs">
<div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-apple-blue border-t-transparent" />
<p className="text-sm text-apple-label-secondary">
加载 3D 模型中...
<div className="absolute inset-0 flex items-center justify-center bg-[#f1f4f5]/90 backdrop-blur-[32px]">
<div className="w-[280px] rounded-[6px] bg-white/70 px-6 py-5 shadow-[0_24px_70px_rgba(45,51,53,0.10)]">
<div className="mb-4 flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#5a6062]/60">
MODEL STREAM
</span>
<span className="font-mono text-[10px] font-semibold text-[#2d3335]">
SYNC
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-[4px] bg-black/[0.05]">
<div className="h-full w-2/3 animate-pulse rounded-[4px] bg-[#2d3335]" />
</div>
<p className="mt-4 text-[13px] font-semibold text-[#2d3335]">
3D 模型流正在建立
</p>
<p className="mt-1 text-[12px] leading-5 text-[#5a6062]">
正在读取模型文件与场景网格
</p>
</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-apple-bg-primary/95 backdrop-blur-sm">
<div className="max-w-sm px-6 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-apple-red/10">
<AlertTriangle className="h-8 w-8 text-apple-red" />
<div className="absolute inset-0 flex items-center justify-center bg-[#f1f4f5]/92 backdrop-blur-[32px]">
<div className="w-[360px] rounded-[6px] bg-white/72 px-6 py-5 shadow-[0_28px_80px_rgba(45,51,53,0.14)]">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#5a6062]/55">
MODEL INTEGRITY
</p>
<h3 className="mt-2 text-[18px] font-black leading-tight text-[#2d3335]">
3D 模型加载失败
</h3>
</div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[6px] bg-[#2d3335]/[0.06] text-[#9a3b35]">
<AlertTriangle className="h-5 w-5" strokeWidth={1.8} />
</div>
</div>
<h3 className="mb-2 text-apple-lg font-apple-system font-apple-semibold text-apple-label-primary">
3D 模型加载失败
</h3>
<p className="mb-6 text-apple-md font-apple-system leading-relaxed text-apple-label-secondary">
<p className="rounded-[4px] bg-black/[0.03] px-3 py-2 font-mono text-[12px] leading-5 text-[#5a6062]">
{error}
</p>
<div className="flex flex-col items-center justify-center gap-3 sm:flex-row">
<Button variant="filled" onClick={handleRetry} className="gap-2">
<div className="mt-5 flex items-center gap-3">
<Button
variant="filled"
onClick={handleRetry}
className="h-10 rounded-[4px] px-4 text-[12px] font-bold"
>
<RefreshCw className="h-4 w-4" />
重新加载
</Button>
{retryCount > 0 && (
<span className="text-apple-sm text-apple-label-tertiary">
已重试 {retryCount} 次
<span className="font-mono text-[11px] font-semibold text-[#5a6062]/55">
RETRY {retryCount}
</span>
)}
</div>
<p className="mt-4 text-apple-sm text-apple-label-tertiary">
如果问题持续存在,请检查模型文件或联系技术支持
<p className="mt-4 text-[12px] leading-5 text-[#5a6062]/70">
请核对模型文件是否已同步到本地资源目录,或切换到已有模型的规格款。
</p>
</div>
</div>
@ -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 &&

View File

@ -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(
<ModelViewer
modelUrl="/demo.glb"
enableTranslateGizmo={true}
selectedNodeName={null}
gizmoMode="translate"
/>,
);
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(
<ModelViewer

View File

@ -119,4 +119,22 @@ describe("ModelViewer reload behavior", () => {
expect(loadModelMock).toHaveBeenCalledTimes(1);
});
it("加载模型时会把资产业务尺寸传给 3D 引擎用于单位归一", async () => {
render(
<ModelViewer
modelUrl="/demo.glb"
modelDimensions={{ length: 1200, width: 600, thickness: 25 }}
/>,
);
await act(async () => {
jest.runOnlyPendingTimers();
await Promise.resolve();
});
expect(loadModelMock).toHaveBeenCalledWith("/demo.glb", {
expectedDimensions: { length: 1200, width: 600, thickness: 25 },
});
});
});

View File

@ -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 (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-surface)' }}>
<AppTopNav user={user} />
<AppSideNav />
<AppTopNav user={user} variant={isAssetCenterRoute ? 'workbench' : 'default'} />
{!isAssetCenterRoute && <AppSideNav />}
<main
className={
shouldLockMainViewport
isAssetCenterRoute
? 'mt-16 h-[calc(100dvh-64px)] min-h-[calc(100dvh-64px)] overflow-hidden overscroll-none bg-[#f8f9fa]'
: shouldLockMainViewport
? 'ml-[var(--layout-app-sidebar-width)] mt-14 h-[calc(100vh-56px)] overflow-hidden overscroll-none bg-white'
: 'ml-[var(--layout-app-sidebar-width)] mt-14 min-h-[calc(100vh-56px)]'
}

View File

@ -1,11 +1,33 @@
'use client'
import Link from 'next/link'
import { Search, Bell } from 'lucide-react'
import { usePathname } from 'next/navigation'
import {
ArrowDownToLine,
Bell,
Boxes,
Home,
Layers,
PackageOpen,
Search,
Warehouse,
} from 'lucide-react'
import ThemeToggle from '@/components/ui/ThemeToggle'
import UserMenu from '@/components/ui/UserMenu'
import { APP_BRAND_LOGO_SRC, APP_BRAND_NAME } from '@/lib/branding'
const WORKBENCH_NAV_ITEMS = [
{ href: '/', icon: Home, label: '首页概览' },
{ href: '/asset-center', icon: Boxes, label: '资产中心' },
{ href: '/assisted-assembly', icon: Layers, label: '辅助装配' },
{ href: '/draft-groups', icon: PackageOpen, label: '草稿组' },
{ href: '/intake', icon: ArrowDownToLine, label: '正式产品' },
{ href: '/warehouse', icon: Warehouse, label: '仓库管理' },
]
const WORKBENCH_BRAND_NAME =
APP_BRAND_NAME === '由壹点「产品开发平台」' ? '壹点产品开发平台' : APP_BRAND_NAME
interface AppTopNavProps {
user?: {
id: string
@ -15,9 +37,65 @@ interface AppTopNavProps {
avatar: string | null
department: { id: string; name: string } | null
} | null
variant?: 'default' | 'workbench'
}
export function AppTopNav({ user }: AppTopNavProps) {
export function AppTopNav({ user, variant = 'default' }: AppTopNavProps) {
const pathname = usePathname() ?? '/'
if (variant === 'workbench') {
return (
<nav className="fixed left-0 right-0 top-0 z-50 flex h-16 items-center border-b border-black/[0.04] bg-white/[0.92] px-8 font-[Manrope,sans-serif] tracking-tight backdrop-blur-2xl">
<Link href="/" className="flex min-w-[172px] items-center gap-2.5">
<img
src={APP_BRAND_LOGO_SRC}
alt=""
aria-hidden="true"
className="h-9 w-9 rounded-[6px] object-cover shadow-[0_10px_24px_rgba(45,51,53,0.12)]"
/>
<div className="leading-none">
<p className="text-[13px] font-black text-[#2d3335]">{WORKBENCH_BRAND_NAME}</p>
<p className="mt-1 text-[10px] font-semibold text-[#5a6062]/55">资产中心</p>
</div>
</Link>
<div className="mx-7 h-8 w-px bg-black/[0.05]" />
<div className="flex items-center gap-1.5">
{WORKBENCH_NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = item.href === '/' ? pathname === '/' : pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex h-10 items-center gap-2 rounded-[6px] px-3 text-[13px] font-semibold transition',
active
? 'bg-black/[0.04] text-[#2d3335] shadow-sm'
: 'text-[#5a6062]/70 hover:bg-black/[0.025] hover:text-[#2d3335]',
].join(' ')}
>
<Icon size={15} strokeWidth={1.8} />
<span>{item.label}</span>
</Link>
)
})}
</div>
<div className="ml-auto flex items-center gap-3 border-l border-black/[0.04] pl-6">
<button className="relative flex h-9 w-9 items-center justify-center rounded-[6px] text-[#5a6062] transition hover:bg-black/[0.03] hover:text-[#2d3335]">
<Bell size={16} strokeWidth={1.7} />
<span className="absolute right-2.5 top-2.5 h-1.5 w-1.5 rounded-full bg-red-500" />
</button>
<ThemeToggle />
<UserMenu user={user ?? null} />
</div>
</nav>
)
}
return (
<nav
className="fixed top-0 left-0 right-0 z-50 flex h-14 items-center justify-between px-6 font-[Manrope,sans-serif] tracking-tight"
@ -72,7 +150,7 @@ export function AppTopNav({ user }: AppTopNavProps) {
<ThemeToggle />
{user && <UserMenu user={user} />}
<UserMenu user={user ?? null} />
</div>
</nav>
)

View File

@ -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 (
<div
aria-hidden="true"
data-testid="asset-workbench-empty-backdrop"
className={cn(
'pointer-events-none absolute inset-0 isolate overflow-hidden bg-[#0c0e10] text-white',
className,
)}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_42%,rgba(172,179,184,0.12),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.035),transparent_34%,rgba(255,255,255,0.018))]" />
<div className="absolute inset-0 opacity-60 bg-[linear-gradient(rgba(220,227,233,0.055)_1px,transparent_1px),linear-gradient(90deg,rgba(220,227,233,0.055)_1px,transparent_1px)] bg-[size:44px_44px]" />
<div className="absolute inset-0 opacity-40 bg-[linear-gradient(rgba(191,216,229,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(191,216,229,0.045)_1px,transparent_1px)] bg-[size:176px_176px]" />
<div className="absolute inset-x-[14%] top-[22%] h-px bg-gradient-to-r from-transparent via-white/12 to-transparent" />
<div className="absolute inset-x-[20%] bottom-[21%] h-px bg-gradient-to-r from-transparent via-[#bfd8e5]/14 to-transparent" />
<div className="absolute left-1/2 top-1/2 h-[44%] w-[52%] -translate-x-1/2 -translate-y-1/2 rounded-[8px] border border-white/[0.07] bg-white/[0.015] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]" />
<div className="absolute left-1/2 top-1/2 h-[30%] w-[38%] -translate-x-1/2 -translate-y-1/2 rounded-[8px] border border-dashed border-[#bfd8e5]/18" />
{relationLines.map(lineClassName => (
<span
key={lineClassName}
data-testid="workbench-relation-line"
className={cn('absolute h-px origin-left bg-gradient-to-r from-transparent via-[#bfd8e5]/24 to-transparent', lineClassName)}
/>
))}
<div
data-testid="packaging-wireframe"
className="absolute left-1/2 top-[48%] h-[168px] w-[252px] -translate-x-1/2 -translate-y-1/2 rounded-[8px] border border-[#bfd8e5]/18 bg-[#bfd8e5]/[0.018]"
>
<span className="absolute left-5 top-5 h-[58px] w-[88px] rounded-[4px] border border-white/10" />
<span className="absolute bottom-5 right-6 h-[62px] w-[104px] rounded-[4px] border border-white/10" />
<span className="absolute left-1/2 top-0 h-full w-px bg-white/[0.055]" />
<span className="absolute left-0 top-1/2 h-px w-full bg-white/[0.055]" />
</div>
{componentPlates.map(plateClassName => (
<div
key={plateClassName}
data-testid="meta-component-plate"
className={cn(
'absolute rounded-[6px] border border-white/[0.09] bg-[#dde3e9]/[0.045] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]',
plateClassName,
)}
>
<span className="absolute left-3 top-3 h-1.5 w-8 rounded-full bg-[#bfd8e5]/25" />
<span className="absolute bottom-3 left-3 h-1 w-12 rounded-full bg-white/10" />
<span className="absolute right-3 top-3 h-5 w-5 rounded-[4px] border border-white/10" />
</div>
))}
{hardwareNodes.map(nodeClassName => (
<span
key={nodeClassName}
data-testid="hardware-node"
className={cn(
'absolute h-4 w-4 rounded-full border border-[#bfd8e5]/26 bg-[#0c0e10] shadow-[0_0_0_4px_rgba(191,216,229,0.04)]',
nodeClassName,
)}
>
<span className="absolute left-1/2 top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#bfd8e5]/42" />
</span>
))}
<div
data-testid="workbench-hud"
className="absolute left-8 top-8 w-[184px] rounded-[8px] bg-zinc-950/64 p-3 text-[10px] text-white/58 shadow-sm ring-1 ring-white/10 backdrop-blur-2xl"
>
<div className="flex items-center justify-between gap-4">
<span className="font-apple-mono uppercase tracking-apple-normal">META COMPONENT</span>
<span className="h-1.5 w-1.5 rounded-full bg-[#bfd8e5]/50" />
</div>
<div className="mt-2 h-px bg-white/10" />
<div className="mt-2 grid grid-cols-3 gap-1">
<span className="h-1 rounded-full bg-white/20" />
<span className="h-1 rounded-full bg-white/12" />
<span className="h-1 rounded-full bg-white/16" />
</div>
</div>
<div
data-testid="workbench-hud"
className="absolute bottom-8 right-8 w-[156px] rounded-[8px] bg-zinc-950/64 p-3 shadow-sm ring-1 ring-white/10 backdrop-blur-2xl"
>
<div className="grid grid-cols-4 gap-1.5">
<span className="h-6 rounded-[4px] border border-white/10 bg-white/[0.035]" />
<span className="h-6 rounded-[4px] border border-white/10 bg-white/[0.055]" />
<span className="h-6 rounded-[4px] border border-white/10 bg-white/[0.03]" />
<span className="h-6 rounded-[4px] border border-white/10 bg-[#bfd8e5]/[0.08]" />
</div>
<div className="mt-2 flex items-center justify-between text-[9px] font-apple-mono uppercase tracking-apple-normal text-white/45">
<span>4D LINK</span>
<span>READY</span>
</div>
</div>
<div
data-testid="workbench-hud"
className="absolute bottom-8 left-8 flex items-center gap-2 rounded-[8px] bg-zinc-950/64 px-3 py-2 text-[10px] font-apple-mono uppercase tracking-apple-normal text-white/45 shadow-sm ring-1 ring-white/10 backdrop-blur-2xl"
>
<span className="h-1.5 w-8 rounded-full bg-[#bfd8e5]/30" />
<span>ATELIER GRID</span>
</div>
<div className="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-[#0c0e10] to-transparent" />
</div>
)
}

View File

@ -6,6 +6,7 @@ import { Box } from 'lucide-react'
import { ViewportStageShell, type ViewportPresetKey } from './ViewportStageShell'
import {
ThreeDEngine,
type ModelExpectedDimensions,
type NodePositionOffset,
type NodeRotationState,
} from '@/lib/3d/engine'
@ -16,6 +17,7 @@ import { cn } from '@/lib/utils'
interface ModelPreviewPanelProps {
modelUrl?: string | null
modelDimensions?: ModelExpectedDimensions | null
/** @deprecated 仅用于兼容旧逻辑 */
previewNodes?: unknown[]
/** 统一 3D 世界节点 */
@ -28,6 +30,8 @@ interface ModelPreviewPanelProps {
emptyDescription?: string
/** 空状态图标 */
emptyIcon?: React.ReactNode
/** 空状态底层工作台背景 */
emptyBackdrop?: ReactNode
className?: string
/** 面板顶部标题 */
title?: string
@ -103,12 +107,14 @@ interface ModelPreviewPanelProps {
export function ModelPreviewPanel({
modelUrl,
modelDimensions = null,
previewNodes,
worldNodes,
worldFrameMode = 'initial',
emptyTitle = '未选择型号',
emptyDescription = '从列表中选择一个项目即可在此处预览其 3D 模型。',
emptyIcon,
emptyBackdrop,
className,
title = '资产中心',
showFpsBadge = true,
@ -236,6 +242,7 @@ export function ModelPreviewPanel({
className,
)}
>
{emptyBackdrop ? <div className="absolute inset-0 z-0">{emptyBackdrop}</div> : null}
{topRightOverlay ? <div className="absolute top-6 right-6 z-20">{topRightOverlay}</div> : null}
{overlayLeft ? (
<div className={cn('absolute z-20 h-full', overlayLeftClassName || 'left-24 top-1/2 -translate-y-1/2')}>
@ -256,7 +263,14 @@ export function ModelPreviewPanel({
<div className="absolute bottom-8 left-1/2 z-20 -translate-x-1/2">{overlayBottomCenter}</div>
) : null}
{overlayBottomRight ? <div className="absolute bottom-8 right-8 z-20">{overlayBottomRight}</div> : null}
<div className="flex flex-col items-center gap-4">
<div
className={cn(
'relative z-10 flex flex-col items-center gap-4',
emptyBackdrop
? 'rounded-[12px] bg-white/76 px-7 py-6 shadow-[0_24px_70px_rgba(0,0,0,0.16)] ring-1 ring-white/60 backdrop-blur-xl'
: '',
)}
>
<div className="flex h-16 w-16 items-center justify-center rounded-[2rem] bg-white text-apple-label-tertiary shadow-apple-sm ring-1 ring-black/5">
{emptyIcon || <Box size={32} strokeWidth={1.5} />}
</div>
@ -297,6 +311,7 @@ export function ModelPreviewPanel({
<Suspense fallback={null}>
<ModelViewer
modelUrl={modelUrl ?? undefined}
modelDimensions={modelDimensions}
worldNodes={worldNodes}
worldFrameMode={worldFrameMode}
selectedNodeName={selectedNodeName}
@ -322,6 +337,8 @@ export function ModelPreviewPanel({
showPanelLeaders={showPanelLeaders}
showHoleHighlights={showHoleHighlights}
showSceneGrid={effectiveShowSceneGrid}
enableTranslateGizmo={enableTranslateGizmo}
gizmoMode={gizmoMode}
/>
</Suspense>
</div>

View File

@ -7,9 +7,6 @@ import {
RotateCw,
Square,
BoxSelect,
Monitor,
Search,
RotateCcw
} from 'lucide-react'
import { cn } from '@/lib/utils'
@ -40,7 +37,7 @@ interface ViewportStageShellProps {
overlayCenter?: ReactNode
overlayBottomCenter?: ReactNode
overlayBottomRight?: ReactNode
/** 允许外部注入额外的侧边栏项目(如辅助装配的零件分类) */
extraSidebarActions?: ReactNode
}
@ -51,14 +48,11 @@ export function ViewportStageShell({
showSceneGrid,
onToggleSceneGrid,
viewportControlsEnabled = true,
showCameraActionButtons = true,
enableTranslateGizmo = true,
gizmoMode = 'translate',
onGizmoModeChange,
activePreset,
onPresetChange,
onFitToScene,
onResetView,
overlayTopRight,
overlayLeft,
overlayLeftClassName,
@ -74,97 +68,68 @@ export function ViewportStageShell({
{/* 3D 内容区 */}
<div className="absolute inset-0 z-0">{children}</div>
{/* 统一侧边工具栏 (Left Center) */}
<div className={cn(
"absolute left-6 top-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-2 p-1.5 rounded-2xl bg-white/70 shadow-apple-2xl backdrop-blur-xl ring-1 ring-black/5 transition-all duration-300"
)}>
{/* 指示器 */}
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-black shadow-apple-sm">
<Monitor className="h-5 w-5 text-white" />
</div>
<div className="my-1 h-[1px] w-6 bg-apple-separator/10" />
{/* 交互模式 (MOVE/ROTATE) */}
{/* Obsidian Atelier vertical viewport tools */}
<div className="absolute left-8 top-1/2 z-30 flex -translate-y-1/2 flex-col items-center gap-1 rounded-[8px] bg-white/92 p-1.5 shadow-[0_18px_48px_rgba(45,51,53,0.14)] ring-1 ring-black/[0.08] backdrop-blur-xl transition-all duration-300">
{/* 交互模式 */}
{enableTranslateGizmo && (
<div className="flex flex-col items-center gap-1.5">
<ToolbarButton
icon={Move}
label="MOVE"
active={gizmoMode === 'translate'}
onClick={() => onGizmoModeChange?.('translate')}
<ToolbarButton
icon={Move}
label="移动"
active={gizmoMode === 'translate'}
onClick={() => onGizmoModeChange?.('translate')}
/>
<ToolbarButton
icon={RotateCw}
label="ROTATE"
active={gizmoMode === 'rotate'}
onClick={() => onGizmoModeChange?.('rotate')}
<ToolbarButton
icon={RotateCw}
label="旋转"
active={gizmoMode === 'rotate'}
onClick={() => onGizmoModeChange?.('rotate')}
/>
</div>
)}
<div className="my-1 h-[1px] w-6 bg-apple-separator/10" />
<div className="my-0.5 h-px w-6 bg-[#2d3335]/10" />
{showCameraActionButtons && (
<>
<div className="flex flex-col items-center gap-1.5">
<ToolbarButton
icon={Search}
label="FIT"
onClick={onFitToScene}
disabled={!viewportControlsEnabled}
/>
<ToolbarButton
icon={RotateCcw}
label="RESET"
onClick={onResetView}
disabled={!viewportControlsEnabled}
/>
</div>
<div className="my-1 h-[1px] w-6 bg-apple-separator/10" />
</>
)}
{/* 视口预设 (FRONT/TOP/ISO) */}
{/* 视口预设 */}
<div className="flex flex-col items-center gap-1.5">
<ToolbarButton
icon={Square}
label="FRONT"
active={activePreset === 'front'}
onClick={() => onPresetChange?.('front')}
<ToolbarButton
icon={Square}
label="正视"
active={activePreset === 'front'}
onClick={() => onPresetChange?.('front')}
disabled={!viewportControlsEnabled}
/>
<ToolbarButton
icon={Grid3X3}
label="TOP"
active={activePreset === 'top'}
onClick={() => onPresetChange?.('top')}
<ToolbarButton
icon={Grid3X3}
label="顶视"
active={activePreset === 'top'}
onClick={() => onPresetChange?.('top')}
disabled={!viewportControlsEnabled}
/>
<ToolbarButton
icon={BoxSelect}
label="ISO"
active={activePreset === 'isometric'}
onClick={() => onPresetChange?.('isometric')}
<ToolbarButton
icon={BoxSelect}
label="等轴测"
active={activePreset === 'isometric'}
onClick={() => onPresetChange?.('isometric')}
disabled={!viewportControlsEnabled}
/>
</div>
<div className="my-1 h-[1px] w-6 bg-apple-separator/10" />
<div className="my-0.5 h-px w-6 bg-[#2d3335]/10" />
{/* 网格开关 (还原功能) */}
<ToolbarButton
icon={Grid3X3}
label="GRID"
active={showSceneGrid}
onClick={onToggleSceneGrid}
{/* 网格开关 */}
<ToolbarButton
icon={Grid3X3}
label="网格"
active={showSceneGrid}
onClick={onToggleSceneGrid}
disabled={!viewportControlsEnabled}
/>
{/* 外部注入的额外操作 (如辅助装配的分类) */}
{extraSidebarActions && (
<>
<div className="my-1 h-[1px] w-6 bg-apple-separator/10" />
<div className="my-0.5 h-px w-6 bg-[#2d3335]/10" />
{extraSidebarActions}
</>
)}
@ -189,8 +154,8 @@ export function ViewportStageShell({
)
}
function ToolbarButton({
icon: Icon, label, active, onClick, disabled
function ToolbarButton({
icon: Icon, label, active, onClick, disabled
}: { icon: any, label: string, active?: boolean, onClick?: () => void, disabled?: boolean }) {
return (
<button
@ -198,14 +163,14 @@ function ToolbarButton({
onClick={onClick}
disabled={disabled}
className={cn(
"flex flex-col items-center justify-center h-12 w-12 rounded-xl transition-all duration-200",
active
? "bg-apple-blue text-white shadow-apple-md scale-105"
: "text-apple-label-tertiary hover:bg-black/5 hover:text-apple-label-primary disabled:opacity-30 disabled:hover:scale-100"
"flex h-10 w-10 flex-col items-center justify-center rounded-[6px] transition-all duration-200",
active
? "bg-[#47646b] text-white shadow-[0_8px_18px_rgba(71,100,107,0.24)]"
: "text-[#5a6062] hover:bg-[#f1f4f5] hover:text-[#2d3335] disabled:opacity-30 disabled:hover:scale-100"
)}
>
<Icon size={18} strokeWidth={active ? 2.5 : 1.5} />
<span className="mt-1 text-[8px] font-black uppercase tracking-tighter">{label}</span>
<Icon size={16} strokeWidth={active ? 2.3 : 1.5} />
<span className="mt-0.5 font-sans text-[8px] font-bold uppercase leading-none tracking-normal">{label}</span>
</button>
)
}

View File

@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react'
import { AssetWorkbenchEmptyBackdrop } from '../AssetWorkbenchEmptyBackdrop'
describe('AssetWorkbenchEmptyBackdrop', () => {
it('renders a decorative non-interactive workbench layer', () => {
render(<AssetWorkbenchEmptyBackdrop />)
const backdrop = screen.getByTestId('asset-workbench-empty-backdrop')
expect(backdrop).toHaveAttribute('aria-hidden', 'true')
expect(backdrop).toHaveClass('pointer-events-none')
expect(backdrop).toHaveClass('absolute')
})
it('uses CSS/HTML primitives instead of external media', () => {
const { container } = render(<AssetWorkbenchEmptyBackdrop />)
expect(container.querySelector('img')).not.toBeInTheDocument()
expect(container.querySelector('svg')).not.toBeInTheDocument()
expect(container.querySelector('canvas')).not.toBeInTheDocument()
expect(container.querySelector('picture')).not.toBeInTheDocument()
})
it('contains quiet meta component, hardware, packaging, and HUD elements', () => {
render(<AssetWorkbenchEmptyBackdrop />)
expect(screen.getAllByTestId('meta-component-plate')).toHaveLength(4)
expect(screen.getAllByTestId('hardware-node')).toHaveLength(6)
expect(screen.getAllByTestId('workbench-relation-line')).toHaveLength(3)
expect(screen.getByTestId('packaging-wireframe')).toBeInTheDocument()
expect(screen.getAllByTestId('workbench-hud')).toHaveLength(3)
})
it('allows viewport integrations to adjust the outer layer', () => {
render(<AssetWorkbenchEmptyBackdrop className="relative opacity-80" />)
const backdrop = screen.getByTestId('asset-workbench-empty-backdrop')
expect(backdrop).toHaveClass('relative')
expect(backdrop).not.toHaveClass('absolute')
expect(backdrop).toHaveClass('opacity-80')
})
})

View File

@ -1,9 +1,11 @@
import { render, screen } from '@testing-library/react'
import { ModelPreviewPanel } from '../ModelPreviewPanel'
const mockModelViewer = jest.fn(() => <div data-testid="model-viewer" />)
jest.mock('@/components/3d/ModelViewer', () => ({
__esModule: true,
ModelViewer: () => <div data-testid="model-viewer" />,
ModelViewer: (props: unknown) => mockModelViewer(props),
}))
jest.mock('@/lib/3d/camera-controller', () => ({
@ -31,6 +33,10 @@ jest.mock('@/lib/3d/engine', () => ({
}))
describe('ModelPreviewPanel', () => {
beforeEach(() => {
mockModelViewer.mockClear()
})
it('keeps the top-right actions visible in empty state', () => {
render(
<ModelPreviewPanel
@ -45,4 +51,39 @@ describe('ModelPreviewPanel', () => {
expect(screen.getByRole('button', { name: '上传3D模型' })).toBeInTheDocument()
expect(screen.getByText('支持上传 .zip / .glb / .gltf / .obj')).toBeInTheDocument()
})
it('renders an optional workbench backdrop behind the empty state copy', () => {
render(
<ModelPreviewPanel
modelUrl={null}
showFpsBadge={false}
emptyTitle="选择规格款预览 3D 模型"
emptyBackdrop={<div data-testid="empty-workbench-backdrop" />}
/>,
)
expect(screen.getByTestId('empty-workbench-backdrop')).toBeInTheDocument()
expect(screen.getByText('选择规格款预览 3D 模型')).toBeInTheDocument()
})
it('passes the active gizmo controls into the 3D viewer', () => {
render(
<ModelPreviewPanel
modelUrl="/demo.glb"
modelDimensions={{ length: 1200, width: 600, thickness: 25 }}
showFpsBadge={false}
enableTranslateGizmo
gizmoMode="rotate"
/>,
)
expect(screen.getByTestId('model-viewer')).toBeInTheDocument()
expect(mockModelViewer).toHaveBeenCalledWith(
expect.objectContaining({
enableTranslateGizmo: true,
gizmoMode: 'rotate',
modelDimensions: { length: 1200, width: 600, thickness: 25 },
}),
)
})
})

View File

@ -10,24 +10,71 @@ import { useRouter } from 'next/navigation'
import { User, LogOut, Settings, ChevronDown } from 'lucide-react'
import Image from '@/components/ui/Image'
interface UserMenuProps {
user: {
interface UserMenuUser {
id: string;
name: string;
email: string;
role: string;
avatar: string | null;
department: {
id: string;
name: string;
email: string;
role: string;
avatar: string | null;
department: {
id: string;
name: string;
} | null;
} | null;
}
interface UserMenuProps {
user: UserMenuUser | null;
}
interface AuthMeResponse {
success?: boolean;
data?: {
authenticated?: boolean;
user?: UserMenuUser;
};
}
export default function UserMenu({ user }: UserMenuProps) {
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const [fetchedUser, setFetchedUser] = useState<UserMenuUser | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const resolvedUser = user ?? fetchedUser
useEffect(() => {
if (user) {
setFetchedUser(null)
return
}
let active = true
void (async () => {
try {
const response = await fetch('/api/auth/me', { cache: 'no-store' })
const payload = (await response.json().catch(() => null)) as AuthMeResponse | null
if (!active) {
return
}
if (response.ok && payload?.success && payload.data?.authenticated && payload.data.user) {
setFetchedUser(payload.data.user)
return
}
setFetchedUser(null)
} catch {
if (active) {
setFetchedUser(null)
}
}
})()
return () => {
active = false
}
}, [user])
// 点击外部关闭菜单
useEffect(() => {
@ -59,8 +106,17 @@ export default function UserMenu({ user }: UserMenuProps) {
}
}
if (!user) {
return null
if (!resolvedUser) {
return (
<button
type="button"
aria-label="用户中心"
onClick={() => router.push('/login')}
className="flex h-9 w-9 items-center justify-center rounded-[6px] border border-black/[0.06] bg-black/[0.025] text-[#5a6062] transition hover:bg-black/[0.04] hover:text-[#2d3335]"
>
<User className="h-4 w-4" strokeWidth={1.7} />
</button>
)
}
// 角色显示名称
@ -92,15 +148,15 @@ export default function UserMenu({ user }: UserMenuProps) {
color: 'var(--color-label-secondary)',
}}
>
{user.avatar ? (
{resolvedUser.avatar ? (
<Image
src={user.avatar}
alt={user.name}
src={resolvedUser.avatar}
alt={resolvedUser.name}
className="w-full h-full rounded-full object-cover"
rounded="full"
/>
) : (
<span>{user.name.charAt(0).toUpperCase()}</span>
<span>{resolvedUser.name.charAt(0).toUpperCase()}</span>
)}
</div>
@ -109,7 +165,7 @@ export default function UserMenu({ user }: UserMenuProps) {
className="text-sm font-apple-system font-apple-medium tracking-apple-tighter"
style={{ color: 'var(--color-label-primary)' }}
>
{user.name}
{resolvedUser.name}
</span>
{/* 下拉图标 */}
@ -142,15 +198,15 @@ export default function UserMenu({ user }: UserMenuProps) {
color: 'var(--color-label-secondary)',
}}
>
{user.avatar ? (
{resolvedUser.avatar ? (
<Image
src={user.avatar}
alt={user.name}
src={resolvedUser.avatar}
alt={resolvedUser.name}
className="w-full h-full rounded-full object-cover"
rounded="full"
/>
) : (
<span>{user.name.charAt(0).toUpperCase()}</span>
<span>{resolvedUser.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div className="flex-1 min-w-0">
@ -158,13 +214,13 @@ export default function UserMenu({ user }: UserMenuProps) {
className="text-sm font-apple-system font-apple-semibold truncate"
style={{ color: 'var(--color-label-primary)' }}
>
{user.name}
{resolvedUser.name}
</div>
<div
className="text-xs font-apple-system truncate"
style={{ color: 'var(--color-label-tertiary)' }}
>
{user.email}
{resolvedUser.email}
</div>
</div>
</div>
@ -176,9 +232,9 @@ export default function UserMenu({ user }: UserMenuProps) {
color: 'var(--color-label-secondary)',
}}
>
{roleNames[user.role] || user.role}
{roleNames[resolvedUser.role] || resolvedUser.role}
</span>
{user.department && (
{resolvedUser.department && (
<span
className="px-2 py-0.5 rounded-full text-xs font-apple-system"
style={{
@ -186,7 +242,7 @@ export default function UserMenu({ user }: UserMenuProps) {
color: 'var(--color-label-secondary)',
}}
>
{user.department.name}
{resolvedUser.department.name}
</span>
)}
</div>

View File

@ -78,6 +78,31 @@ describe("CameraController", () => {
expect(controls.target.z).toBeCloseTo(0);
});
it("预设取景应只按导航模型计算,不被辅助工具物体拉远", () => {
const { scene, controller } = createController();
const modelRoot = new THREE.Group();
const panel = new THREE.Mesh(
new THREE.BoxGeometry(120, 6, 60),
new THREE.MeshBasicMaterial(),
);
modelRoot.add(panel);
scene.add(modelRoot);
const helper = new THREE.Mesh(
new THREE.BoxGeometry(5000, 5000, 5000),
new THREE.MeshBasicMaterial(),
);
helper.name = "transform-helper-oversized-picker";
scene.add(helper);
controller.setNavigationModel(modelRoot);
const box = controller.getBounds();
const size = box.getSize(new THREE.Vector3());
expect(Math.max(size.x, size.y, size.z)).toBeLessThan(130);
});
it("应能直接开关交互控制", () => {
const { controls, controller } = createController();

View File

@ -0,0 +1,30 @@
import { resolveModelUnitScaleFromBounds } from "../model-unit-scale";
describe("resolveModelUnitScaleFromBounds", () => {
it("scales meter-authored board models to millimeter asset dimensions", () => {
expect(
resolveModelUnitScaleFromBounds(
{ x: 1.2, y: 0.025, z: 0.6 },
{ length: 1200, width: 600, thickness: 25 },
),
).toBe(1000);
});
it("keeps models that already match millimeter dimensions unchanged", () => {
expect(
resolveModelUnitScaleFromBounds(
{ x: 1200, y: 25, z: 600 },
{ length: 1200, width: 600, thickness: 25 },
),
).toBe(1);
});
it("does not scale when source and expected ratios disagree", () => {
expect(
resolveModelUnitScaleFromBounds(
{ x: 2, y: 0.02, z: 0.4 },
{ length: 1200, width: 600, thickness: 25 },
),
).toBe(1);
});
});

View File

@ -0,0 +1,27 @@
import { shouldApplyZUpToYUpRotation } from '../model-up-axis'
describe('shouldApplyZUpToYUpRotation', () => {
it('keeps Y-up GLB boards flat on the ground plane', () => {
expect(
shouldApplyZUpToYUpRotation('.glb', { x: 12, y: 0.25, z: 6 }),
).toBe(false)
})
it('converts Z-up GLB boards to the platform Y-up frame', () => {
expect(
shouldApplyZUpToYUpRotation('.glb', { x: 12, y: 6, z: 0.25 }),
).toBe(true)
})
it('keeps Y-up GLB legs vertical', () => {
expect(
shouldApplyZUpToYUpRotation('.glb', { x: 0.5, y: 7.2, z: 0.5 }),
).toBe(false)
})
it('converts OBJ/CAD assets by default', () => {
expect(
shouldApplyZUpToYUpRotation('.obj', { x: 12, y: 6, z: 0.25 }),
).toBe(true)
})
})

View File

@ -136,6 +136,7 @@ export class CameraController {
private controls: OrbitControls
private scene: THREE.Scene
private pathPlanner: CameraPathPlanner | null = null
private navigationModel: THREE.Object3D | null = null
// 动画状态
private animating = false
@ -158,6 +159,8 @@ export class CameraController {
}
setNavigationModel(model: THREE.Object3D | null): void {
this.navigationModel = model
if (!model) {
this.pathPlanner = null
return
@ -198,14 +201,23 @@ export class CameraController {
if (target) {
box.setFromObject(target)
} else {
// 遍历场景中所有可见的 Mesh
this.scene.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.visible) {
box.expandByObject(child)
}
})
return box
}
if (this.navigationModel) {
this.navigationModel.updateMatrixWorld(true)
box.setFromObject(this.navigationModel)
if (!box.isEmpty()) {
return box
}
}
// 遍历场景中所有可见的 Mesh
this.scene.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.visible) {
box.expandByObject(child)
}
})
return box
}
@ -600,6 +612,7 @@ export class CameraController {
const view = this.buildViewForBounds(box, {
direction: preset.direction,
distanceMultiplier: preset.distanceMultiplier,
padding: 1.08,
})
if (!view) return Promise.resolve()
@ -887,6 +900,8 @@ export class CameraController {
// 清理引用(不销毁外部传入的对象)
// camera, controls, scene 由外部管理
this.navigationModel = null
this.pathPlanner = null
}
}

View File

@ -15,6 +15,11 @@ import {
} from "./engine-highlight-resources";
import { computeExplodedWorldOffset } from "./explosion-math";
import { computeCenteredModelPlacement } from "./model-placement";
import {
resolveModelUnitScaleFromBounds,
type ModelExpectedDimensions,
} from "./model-unit-scale";
import { shouldApplyZUpToYUpRotation } from "./model-up-axis";
import { startRenderLoop, stopRenderLoop } from "./engine-render-loop";
import { getMeshWorldGeometryCenter } from "./mesh-geometry-center";
import {
@ -80,6 +85,12 @@ interface EngineWorldLoadOptions {
frame?: EngineWorldFrameMode;
}
interface EngineModelLoadOptions {
expectedDimensions?: ModelExpectedDimensions | null;
}
export type { ModelExpectedDimensions };
interface CameraStateSnapshot {
position: THREE.Vector3;
target: THREE.Vector3;
@ -351,19 +362,74 @@ export class ThreeDEngine {
this.environmentTexture = setupRoomEnvironment(this.renderer, this.scene);
}
private setupSceneGrid(): void {
private createSceneGrid(size: number, divisions: number): THREE.GridHelper {
const grid = new THREE.GridHelper(
PLATFORM_VIEWPORT_GRID_SIZE,
PLATFORM_VIEWPORT_GRID_DIVISIONS,
size,
divisions,
PLATFORM_VIEWPORT_GRID_MAJOR_COLOR,
PLATFORM_VIEWPORT_GRID_MINOR_COLOR,
);
grid.name = "__scene_grid__";
grid.visible = false;
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
gridMaterials.forEach((material) => {
if (material instanceof THREE.LineBasicMaterial) {
material.transparent = true;
material.opacity = 0.38;
material.depthWrite = false;
}
});
return grid;
}
private replaceSceneGrid(size: number, divisions: number): void {
const wasVisible = this.sceneGrid?.visible ?? false;
if (this.sceneGrid) {
this.scene.remove(this.sceneGrid);
this.sceneGrid.geometry.dispose();
this.disposeMaterial(this.sceneGrid.material);
this.sceneGrid = null;
}
const grid = this.createSceneGrid(size, divisions);
grid.visible = wasVisible;
this.scene.add(grid);
this.sceneGrid = grid;
}
private setupSceneGrid(): void {
this.replaceSceneGrid(
PLATFORM_VIEWPORT_GRID_SIZE,
PLATFORM_VIEWPORT_GRID_DIVISIONS,
);
}
private updateSceneGridForBounds(box: THREE.Box3): void {
if (box.isEmpty()) {
return;
}
const modelSize = box.getSize(new THREE.Vector3());
const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1);
const gridSize =
maxDim < 10
? Math.max(maxDim * 6, 8)
: maxDim < 100
? Math.max(maxDim * 4, 80)
: Math.max(maxDim * 1.6, 1600);
const targetCellSize =
maxDim < 10
? Math.max(maxDim / 16, 0.08)
: maxDim < 100
? Math.max(maxDim / 16, 0.75)
: Math.max(maxDim / 32, 25);
const divisions = Math.round(
THREE.MathUtils.clamp(gridSize / targetCellSize, 24, 120),
);
this.replaceSceneGrid(gridSize, divisions);
}
private getEmptyWorkspaceFrame(): {
box: THREE.Box3;
target: THREE.Vector3;
@ -588,6 +654,7 @@ export class ThreeDEngine {
const positionedBox = new THREE.Box3().setFromObject(model);
if (!positionedBox.isEmpty()) {
this.updateSceneGridForBounds(positionedBox);
this.modelOriginalCenter.copy(
positionedBox.getCenter(new THREE.Vector3()),
);
@ -663,7 +730,18 @@ export class ThreeDEngine {
return;
}
model.rotation.x = -Math.PI / 2;
const sourceBox = new THREE.Box3().setFromObject(model);
const sourceSize = sourceBox.getSize(new THREE.Vector3());
if (
shouldApplyZUpToYUpRotation(extension, {
x: sourceSize.x,
y: sourceSize.y,
z: sourceSize.z,
})
) {
model.rotation.x = -Math.PI / 2;
model.updateMatrixWorld(true);
}
if (extension === ".obj") {
this.prepareObjModel(model);
}
@ -746,9 +824,29 @@ export class ThreeDEngine {
* 鍔犺浇glTF妯″瀷
* @param url 妯″瀷鏂囦欢URL
* @returns Promise<THREE.Group> 鍔犺浇鐨勬ā鍨嬪璞? */
async loadModel(url: string): Promise<THREE.Group> {
async loadModel(
url: string,
options: EngineModelLoadOptions = {},
): Promise<THREE.Group> {
const { model, extension } = await this.loadModelAsset(url);
const box = new THREE.Box3().setFromObject(model);
let box = new THREE.Box3().setFromObject(model);
if (!box.isEmpty()) {
const sourceSize = box.getSize(new THREE.Vector3());
const unitScale = resolveModelUnitScaleFromBounds(
{
x: sourceSize.x,
y: sourceSize.y,
z: sourceSize.z,
},
options.expectedDimensions,
);
if (unitScale !== 1) {
model.scale.multiplyScalar(unitScale);
model.updateMatrixWorld(true);
box = new THREE.Box3().setFromObject(model);
}
}
if (SELECTION_DEBUG_ENABLED) {
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
@ -1336,6 +1434,34 @@ export class ThreeDEngine {
return this.findObjectByNodeName(nodeName);
}
resolveDefaultTransformTarget(): {
nodeName: string;
object: THREE.Object3D;
} | null {
const firstMesh = this.getRaycastMeshes()[0] ?? null;
if (firstMesh) {
const hierarchyNames = this.getHierarchyNamesFromObject(firstMesh);
const nodeName =
this.pickBestHierarchyName(hierarchyNames) ||
firstMesh.name ||
"model";
return {
nodeName,
object: this.findNamedAncestor(firstMesh, nodeName),
};
}
if (this.modelRoot) {
return {
nodeName: this.modelRoot.name || "model-root",
object: this.modelRoot,
};
}
return null;
}
rememberSelectionAlias(
selectedNodeName: string | null,
pickedNodeName: string | null,

View File

@ -0,0 +1,117 @@
export type ModelExpectedDimensions = Record<
string,
number | string | null | undefined
>;
export interface ModelBoundsSize {
x: number;
y: number;
z: number;
}
const DIMENSION_KEYS = [
"length",
"width",
"depth",
"height",
"thickness",
];
function resolveUnitToMillimeterMultiplier(
unit: number | string | null | undefined,
): number {
if (typeof unit !== "string") {
return 1;
}
const normalized = unit.trim().toLowerCase();
if (["m", "meter", "meters", "metre", "metres"].includes(normalized)) {
return 1000;
}
if (
[
"cm",
"centimeter",
"centimeters",
"centimetre",
"centimetres",
].includes(normalized)
) {
return 10;
}
return 1;
}
function readExpectedMeasurementsMm(
dimensions: ModelExpectedDimensions | null | undefined,
): number[] {
if (!dimensions) {
return [];
}
const unitMultiplier = resolveUnitToMillimeterMultiplier(
dimensions.unit ?? dimensions.units,
);
return DIMENSION_KEYS.map((key) => dimensions[key])
.filter((value): value is number => typeof value === "number")
.map((value) => value * unitMultiplier)
.filter((value) => Number.isFinite(value) && value > 0)
.sort((left, right) => right - left);
}
function readSourceMeasurements(size: ModelBoundsSize): number[] {
return [size.x, size.y, size.z]
.filter((value) => Number.isFinite(value) && value > 1e-6)
.sort((left, right) => right - left);
}
function normalizeCommonUnitScale(scale: number): number {
for (const candidate of [10, 100, 1000]) {
if (Math.abs(scale - candidate) / candidate <= 0.02) {
return candidate;
}
}
return Number(scale.toFixed(6));
}
export function resolveModelUnitScaleFromBounds(
sourceSize: ModelBoundsSize,
expectedDimensions: ModelExpectedDimensions | null | undefined,
): number {
const sourceMeasurements = readSourceMeasurements(sourceSize);
const expectedMeasurements = readExpectedMeasurementsMm(expectedDimensions);
const pairCount = Math.min(
2,
sourceMeasurements.length,
expectedMeasurements.length,
);
if (pairCount === 0) {
return 1;
}
const ratios = sourceMeasurements
.slice(0, pairCount)
.map((source, index) => expectedMeasurements[index] / source)
.filter((value) => Number.isFinite(value) && value > 0);
if (ratios.length !== pairCount) {
return 1;
}
const scale = ratios.reduce((sum, value) => sum + value, 0) / ratios.length;
if (!Number.isFinite(scale) || scale < 1.5 || scale > 10000) {
return 1;
}
const isConsistent = ratios.every(
(ratio) => Math.abs(ratio - scale) / scale <= 0.18,
);
if (!isConsistent) {
return 1;
}
return normalizeCommonUnitScale(scale);
}

View File

@ -0,0 +1,39 @@
export interface ModelBoundsSize {
x: number
y: number
z: number
}
export function shouldApplyZUpToYUpRotation(
extension: string,
size: ModelBoundsSize,
): boolean {
if (extension === '.obj') {
return true
}
if (extension !== '.glb' && extension !== '.gltf') {
return false
}
const x = Math.abs(size.x)
const y = Math.abs(size.y)
const z = Math.abs(size.z)
if (x <= 0 || y <= 0 || z <= 0) {
return false
}
const minHorizontal = Math.min(x, z)
const maxHorizontal = Math.max(x, z)
const yLooksLikeThickness = y <= minHorizontal * 0.35
const yLooksLikeHeight = y >= maxHorizontal * 1.8
if (yLooksLikeThickness || yLooksLikeHeight) {
return false
}
const zLooksLikeThickness = z <= Math.min(x, y) * 0.35
const zLooksLikeHeight = z >= Math.max(x, y) * 1.8
return zLooksLikeThickness || zLooksLikeHeight
}

View File

@ -5,10 +5,10 @@ export const PLATFORM_VIEWPORT_CAMERA_POSITION = [400, 300, 450] as const
export const PLATFORM_VIEWPORT_CAMERA_FOV = 45
export const PLATFORM_VIEWPORT_CAMERA_FOCAL_LENGTH = 85
export const PLATFORM_VIEWPORT_GRID_SIZE = 4000
export const PLATFORM_VIEWPORT_GRID_DIVISIONS = 40
export const PLATFORM_VIEWPORT_GRID_MAJOR_COLOR = 0xa4acb8
export const PLATFORM_VIEWPORT_GRID_MINOR_COLOR = 0xd3d9de
export const PLATFORM_VIEWPORT_GRID_SIZE = 1600
export const PLATFORM_VIEWPORT_GRID_DIVISIONS = 64
export const PLATFORM_VIEWPORT_GRID_MAJOR_COLOR = 0xcbd3d8
export const PLATFORM_VIEWPORT_GRID_MINOR_COLOR = 0xe9edf0
export const PLATFORM_VIEWPORT_AMBIENT_LIGHT_INTENSITY = 0.52
export const PLATFORM_VIEWPORT_DIRECTIONAL_LIGHT_INTENSITY = 0.72