Merge remote-tracking branch 'origin/codex/xuxiake-mainline' into codex/xuxiake-mainline
This commit is contained in:
commit
925dfc8ad5
179
furniture-3d-system/docs/设计规范/黑曜石工坊统一视觉标准.md
Normal file
179
furniture-3d-system/docs/设计规范/黑曜石工坊统一视觉标准.md
Normal 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 主工作面板或数据列表主体,默认视为视觉规范不通过。
|
||||
- 只要资产中心第一屏退化成普通卡片列表,默认视为产品结构不通过。
|
||||
- 只要元组件被当成普通营销卡片处理,默认视为资产表达不通过。
|
||||
@ -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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 &&
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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)]'
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
117
furniture-3d-system/src/lib/3d/model-unit-scale.ts
Normal file
117
furniture-3d-system/src/lib/3d/model-unit-scale.ts
Normal 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);
|
||||
}
|
||||
39
furniture-3d-system/src/lib/3d/model-up-axis.ts
Normal file
39
furniture-3d-system/src/lib/3d/model-up-axis.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user