Compare commits
No commits in common. "main" and "codex/xuxiake-main-packaging-3100-v2" have entirely different histories.
main
...
codex/xuxi
135
.cursor/rules/3d-engineer.mdc
Normal file
135
.cursor/rules/3d-engineer.mdc
Normal file
@ -0,0 +1,135 @@
|
||||
---
|
||||
description: 3D 工程师规范 - Three.js 渲染、模型处理、资源管理
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 3D 工程师规范
|
||||
|
||||
## 一、技术栈
|
||||
|
||||
| 库 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Three.js | 0.182.0 | 3D 渲染核心 |
|
||||
| @react-three/fiber | 9.5.0 | React 集成 |
|
||||
| @react-three/drei | 10.7.7 | 常用工具 |
|
||||
| GSAP | - | 动画 |
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
src/components/3d/
|
||||
├── Scene.tsx # 主场景
|
||||
├── ModelViewer.tsx # 模型查看器
|
||||
├── AnimationEngine.ts # 动画引擎
|
||||
└── controls/ # 控制器
|
||||
|
||||
src/lib/three/
|
||||
├── loaders/ # 加载器
|
||||
├── materials/ # 材质
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、资源清理(必须)
|
||||
|
||||
组件卸载时必须清理所有资源:
|
||||
|
||||
```typescript
|
||||
public dispose(): void {
|
||||
// 1. 停止动画循环
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId)
|
||||
}
|
||||
|
||||
// 2. 移除事件监听
|
||||
window.removeEventListener('resize', this.handleResize)
|
||||
|
||||
// 3. 深度清理场景对象
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry?.dispose()
|
||||
this.disposeMaterial(obj.material)
|
||||
}
|
||||
})
|
||||
|
||||
// 4. 清理控制器和渲染器
|
||||
this.controls?.dispose()
|
||||
this.renderer?.dispose()
|
||||
this.renderer?.forceContextLoss()
|
||||
}
|
||||
|
||||
private disposeMaterial(material: THREE.Material | THREE.Material[]): void {
|
||||
const materials = Array.isArray(material) ? material : [material]
|
||||
for (const mat of materials) {
|
||||
// 清理所有贴图
|
||||
;['map', 'normalMap', 'roughnessMap', 'metalnessMap'].forEach((key) => {
|
||||
const texture = mat[key]
|
||||
if (texture) texture.dispose()
|
||||
})
|
||||
mat.dispose()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、GSAP 动画清理
|
||||
|
||||
```typescript
|
||||
dispose(): void {
|
||||
if (this.currentTimeline) {
|
||||
this.currentTimeline.kill()
|
||||
this.currentTimeline = null
|
||||
}
|
||||
|
||||
if (this.scene) {
|
||||
this.scene.traverse((object) => {
|
||||
gsap.killTweensOf(object)
|
||||
gsap.killTweensOf(object.position)
|
||||
gsap.killTweensOf(object.rotation)
|
||||
gsap.killTweensOf(object.scale)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、性能优化
|
||||
|
||||
### LOD 策略
|
||||
根据距离切换模型精度
|
||||
|
||||
### 内存管理
|
||||
- 及时释放 geometry、material、texture
|
||||
- 避免在循环中创建新对象
|
||||
|
||||
### 渲染优化
|
||||
- 使用 `React.memo` 包装组件
|
||||
- 避免不必要的重渲染
|
||||
|
||||
---
|
||||
|
||||
## 六、模型存储
|
||||
|
||||
```
|
||||
public/uploads/models/
|
||||
├── demo/ # 演示模型
|
||||
└── {productId}/ # 按产品ID组织
|
||||
├── original/ # 原始上传文件(DWG等)
|
||||
└── model.glb # 转换后的 3D 模型
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、禁止事项
|
||||
|
||||
```
|
||||
❌ 组件卸载时不清理资源
|
||||
❌ 不清理事件监听器
|
||||
❌ 不停止 GSAP 动画
|
||||
❌ 在循环中创建新对象
|
||||
```
|
||||
90
.cursor/rules/autocad.mdc
Normal file
90
.cursor/rules/autocad.mdc
Normal file
@ -0,0 +1,90 @@
|
||||
---
|
||||
description: C#/AutoCAD 工程师规范 - AutoCAD 插件开发
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# C#/AutoCAD 工程师规范
|
||||
|
||||
## 一、项目结构
|
||||
|
||||
```
|
||||
autocad-plugin/
|
||||
├── FurnitureExporter/ # 主项目
|
||||
│ ├── Commands/ # AutoCAD 命令
|
||||
│ ├── Models/ # 数据模型
|
||||
│ ├── Services/ # 业务服务
|
||||
│ └── Utils/ # 工具类
|
||||
├── build.ps1 # 构建脚本
|
||||
└── build-installer.ps1 # 安装包脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、构建命令
|
||||
|
||||
```bash
|
||||
# 一键构建
|
||||
powershell -File .\build.ps1
|
||||
|
||||
# 生成安装包
|
||||
powershell -File .\build-installer.ps1
|
||||
|
||||
# 手动构建(在 FurnitureExporter 目录)
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、AutoCAD API 规范
|
||||
|
||||
### 命令定义
|
||||
```csharp
|
||||
[CommandMethod("EXPORTFURNITURE")]
|
||||
public void ExportFurniture()
|
||||
{
|
||||
var doc = Application.DocumentManager.MdiActiveDocument;
|
||||
var ed = doc.Editor;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 事务处理
|
||||
```csharp
|
||||
using (var tr = doc.TransactionManager.StartTransaction())
|
||||
{
|
||||
// 操作代码
|
||||
tr.Commit();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、输出格式
|
||||
|
||||
### JSON 结构
|
||||
```json
|
||||
{
|
||||
"products": [
|
||||
{
|
||||
"modelNo": "ABC123",
|
||||
"name": "产品名称",
|
||||
"panels": [
|
||||
{
|
||||
"name": "板件1",
|
||||
"geometry": {...},
|
||||
"holes": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
1. **兼容性**:支持 AutoCAD 2020+
|
||||
2. **错误处理**:所有 AutoCAD API 调用需要 try-catch
|
||||
3. **资源释放**:及时释放非托管资源
|
||||
4. **性能**:大量数据处理使用后台线程
|
||||
146
.cursor/rules/backend.mdc
Normal file
146
.cursor/rules/backend.mdc
Normal file
@ -0,0 +1,146 @@
|
||||
---
|
||||
description: 后端工程师规范 - API、Service、Repository 分层架构
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 后端工程师规范
|
||||
|
||||
## 一、分层架构(必须遵循)
|
||||
|
||||
```
|
||||
API Route → Service → Repository → Database
|
||||
↓ ↓
|
||||
Cache DTO
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、API 路由规范
|
||||
|
||||
### 正确写法
|
||||
```typescript
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const data = await xxxService.getXxx(ctx)
|
||||
return jsonSuccess(data)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 禁止写法
|
||||
```typescript
|
||||
// ❌ 禁止在 API 中直接写 Prisma
|
||||
export async function GET(request: NextRequest) {
|
||||
const data = await prisma.xxx.findMany() // 禁止!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Service 层规范
|
||||
|
||||
### 职责
|
||||
1. 调用 Repository 获取数据
|
||||
2. 业务逻辑处理
|
||||
3. 权限检查
|
||||
4. 缓存读写
|
||||
5. 转换为 DTO
|
||||
|
||||
### 模板
|
||||
```typescript
|
||||
async getXxx(ctx: ServiceContext): Promise<XxxDTO> {
|
||||
// 1. 检查缓存
|
||||
const cached = await this.cache.get(CacheKeys.xxx(id))
|
||||
if (cached) return cached
|
||||
|
||||
// 2. 调用 Repository
|
||||
const entity = await this.repository.findById(id)
|
||||
|
||||
// 3. 权限检查
|
||||
this.checkPermission(entity, ctx.user)
|
||||
|
||||
// 4. 转换 DTO
|
||||
const dto = toXxxDTO(entity)
|
||||
|
||||
// 5. 写入缓存
|
||||
await this.cache.set(CacheKeys.xxx(id), dto, CACHE_TTL.MEDIUM)
|
||||
|
||||
return dto
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Repository 层规范
|
||||
|
||||
### 职责
|
||||
只负责数据访问,不含业务逻辑
|
||||
|
||||
### 模板
|
||||
```typescript
|
||||
async findById(id: string): Promise<Entity | null> {
|
||||
return this.prisma.xxx.findUnique({ where: { id, deletedAt: null } })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、必须使用的工具
|
||||
|
||||
| 工具 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `handleRequest` | 统一请求处理 | `handleRequest(request, async (ctx) => {...})` |
|
||||
| `jsonSuccess` | 成功响应 | `return jsonSuccess(data)` |
|
||||
| `jsonError` | 错误响应 | `return jsonError(error)` |
|
||||
| `AppError` | 抛出错误 | `throw new AppError(ErrorCode.XXX_NOT_FOUND)` |
|
||||
| `CacheKeys` | 缓存键管理 | `CacheKeys.product(id)` |
|
||||
| `$transaction` | 事务处理 | `prisma.$transaction(async (tx) => {...})` |
|
||||
|
||||
---
|
||||
|
||||
## 六、API 响应格式
|
||||
|
||||
| 操作类型 | 格式 |
|
||||
|----------|------|
|
||||
| 列表查询 | `{ items: [], pagination: { page, limit, total, hasMore } }` |
|
||||
| 详情/创建/更新 | `{ data: {...} }` |
|
||||
| 删除 | `{ success: true }` |
|
||||
| 错误 | `{ error: { code, message } }` |
|
||||
|
||||
---
|
||||
|
||||
## 七、目录结构
|
||||
|
||||
```
|
||||
src/server/
|
||||
├── core/ # 响应、错误码、常量
|
||||
├── repositories/ # 数据访问层
|
||||
├── services/ # 业务逻辑层
|
||||
├── dto/ # 数据传输对象
|
||||
├── validators/ # 输入验证(Zod)
|
||||
└── cache/ # 缓存层
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、事务处理
|
||||
|
||||
多表操作必须使用事务:
|
||||
|
||||
```typescript
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const product = await tx.product.create({ data: {...} })
|
||||
const panels = await tx.panel.createMany({ data: [...] })
|
||||
return { product, panels }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、缓存策略
|
||||
|
||||
| 数据类型 | TTL | 失效策略 |
|
||||
|---------|-----|---------|
|
||||
| 用户信息 | 5分钟 | 更新时失效 |
|
||||
| 产品详情 | 5分钟 | 更新/删除时失效 |
|
||||
| 产品列表 | 2分钟 | 任何变更失效 |
|
||||
84
.cursor/rules/checklist.mdc
Normal file
84
.cursor/rules/checklist.mdc
Normal file
@ -0,0 +1,84 @@
|
||||
---
|
||||
description: 规则审查员检查清单
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 规则审查员检查清单
|
||||
|
||||
## 一、后端检查
|
||||
|
||||
### 分层架构
|
||||
- [ ] API 是否只调用 Service?
|
||||
- [ ] Service 是否调用 Repository?
|
||||
- [ ] Repository 是否只负责数据访问?
|
||||
|
||||
### 统一处理
|
||||
- [ ] 是否使用 `handleRequest`?
|
||||
- [ ] 是否使用 `jsonSuccess/jsonError`?
|
||||
|
||||
### 错误处理
|
||||
- [ ] 是否使用 `AppError + ErrorCode`?
|
||||
- [ ] 禁止 `throw new Error()`
|
||||
|
||||
### 事务处理
|
||||
- [ ] 多表操作是否使用 `$transaction`?
|
||||
|
||||
---
|
||||
|
||||
## 二、前端检查
|
||||
|
||||
### 元组件
|
||||
- [ ] 是否使用 `@/components/ui/` 元组件?
|
||||
- [ ] 禁止原生 `<button>` / `<input>` / `<select>`
|
||||
|
||||
### 颜色规范
|
||||
- [ ] 是否使用 `text-apple-label-*`?
|
||||
- [ ] 是否使用 `bg-apple-bg-*`?
|
||||
- [ ] 禁止 `text-gray-*` / `bg-gray-*`
|
||||
|
||||
### 圆角规范
|
||||
- [ ] 是否使用 `rounded-apple-md/lg`?
|
||||
|
||||
---
|
||||
|
||||
## 三、审查报告格式
|
||||
|
||||
```
|
||||
## 审查结果
|
||||
|
||||
### 通过项 ✅
|
||||
- 使用了 Service 层
|
||||
- 使用了 Button 元组件
|
||||
|
||||
### 违规项 ❌
|
||||
- API 中直接使用 prisma(第 15 行)
|
||||
→ 修复:将数据库查询移到 Repository 层
|
||||
|
||||
- 使用了 text-gray-500(第 23 行)
|
||||
→ 修复:改为 text-apple-label-secondary
|
||||
|
||||
### 评分:X/10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、快速对照表
|
||||
|
||||
### 后端
|
||||
|
||||
| 正确 | 错误 |
|
||||
|------|------|
|
||||
| `await xxxService.getXxx()` | `await prisma.xxx.findMany()` 在 API 中 |
|
||||
| `handleRequest(request, ...)` | 自己写 try-catch |
|
||||
| `jsonSuccess(data)` | `NextResponse.json({...})` |
|
||||
| `AppError(ErrorCode.XXX)` | `throw new Error('xxx')` |
|
||||
| `$transaction(...)` | 多个独立 prisma 调用 |
|
||||
|
||||
### 前端
|
||||
|
||||
| 正确 | 错误 |
|
||||
|------|------|
|
||||
| `<Button>` | `<button>` |
|
||||
| `text-apple-label-*` | `text-gray-*` |
|
||||
| `bg-apple-bg-*` | `bg-gray-*` |
|
||||
| `rounded-apple-md/lg` | `rounded-lg/xl` |
|
||||
65
.cursor/rules/core-standards.mdc
Normal file
65
.cursor/rules/core-standards.mdc
Normal file
@ -0,0 +1,65 @@
|
||||
---
|
||||
description: 核心规则摘要 - 必须在写代码前阅读并遵循
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 核心规则摘要
|
||||
|
||||
> **这是所有规则的浓缩版,必须 100% 遵循!**
|
||||
|
||||
---
|
||||
|
||||
## 一、后端必遵循(5 条)
|
||||
|
||||
| # | 规则 | 正确示例 | 错误示例 |
|
||||
|---|------|----------|----------|
|
||||
| 1 | **分层架构**:API → Service → Repository | `await xxxService.getXxx()` | `await prisma.xxx.findMany()` |
|
||||
| 2 | **统一请求处理**:使用 `handleRequest` | `handleRequest(request, async (ctx) => {...})` | 自己写 try-catch |
|
||||
| 3 | **统一响应**:使用 `jsonSuccess/jsonError` | `return jsonSuccess(data)` | `return NextResponse.json({...})` |
|
||||
| 4 | **错误抛出**:使用 `AppError + ErrorCode` | `throw new AppError(ErrorCode.XXX)` | `throw new Error('xxx')` |
|
||||
| 5 | **多表操作**:必须使用 `$transaction` | `prisma.$transaction(async (tx) => {...})` | 多个独立的 prisma 调用 |
|
||||
|
||||
---
|
||||
|
||||
## 二、前端必遵循(5 条)
|
||||
|
||||
| # | 规则 | 正确示例 | 错误示例 |
|
||||
|---|------|----------|----------|
|
||||
| 1 | **使用元组件** | `<Button>` `<Input>` `<Modal>` | `<button>` `<input>` `<div className="modal">` |
|
||||
| 2 | **文本颜色** | `text-apple-label-primary/secondary` | `text-gray-900` `text-gray-500` |
|
||||
| 3 | **背景颜色** | `bg-apple-bg-primary/secondary` | `bg-gray-100` `bg-white` |
|
||||
| 4 | **品牌色** | `#008D74` 或 `text-apple-brand` | `blue-500` `green-600` |
|
||||
| 5 | **圆角** | `rounded-apple-md/lg` | `rounded-lg` `rounded-xl` |
|
||||
|
||||
---
|
||||
|
||||
## 三、禁止事项(绝对不能做)
|
||||
|
||||
```
|
||||
❌ API 中直接写 prisma.xxx.findMany() / create() / update()
|
||||
❌ 使用 text-gray-* / bg-gray-* / shadow-* 类名
|
||||
❌ 直接写原生 HTML 元素:<button> <input> <select>
|
||||
❌ 组件内直接 fetch() 请求数据
|
||||
❌ 不使用 AppError 直接 throw new Error()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、文件路径速查
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| 元组件 | `@/components/ui/` |
|
||||
| 后端服务 | `src/server/services/` |
|
||||
| 数据仓库 | `src/server/repositories/` |
|
||||
| 错误码 | `src/server/core/errors.ts` |
|
||||
| 响应工具 | `src/server/core/response.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 五、详细规范位置
|
||||
|
||||
如需更多细节,查看以下文件:
|
||||
- 完整架构规范:`.cursor/rules/backend.mdc`
|
||||
- 视觉标准:`docs/设计规范/苹果风格视觉标准.md`
|
||||
- 开发检查清单:`.cursor/rules/checklist.mdc`
|
||||
146
.cursor/rules/frontend.mdc
Normal file
146
.cursor/rules/frontend.mdc
Normal file
@ -0,0 +1,146 @@
|
||||
---
|
||||
description: 前端工程师规范 - 元组件、苹果风格视觉标准
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 前端工程师规范
|
||||
|
||||
## 一、元组件体系(必须使用)
|
||||
|
||||
| 元组件 | 路径 | 用途 |
|
||||
|--------|------|------|
|
||||
| `Button` | `@/components/ui/Button` | 所有按钮 |
|
||||
| `Input` | `@/components/ui/Input` | 所有输入框 |
|
||||
| `Textarea` | `@/components/ui/Input` | 多行输入 |
|
||||
| `Modal` | `@/components/ui/Modal` | 所有弹窗 |
|
||||
| `Card` | `@/components/ui/Card` | 所有卡片 |
|
||||
| `Toast` | `@/components/ui/Toast` | 所有提示 |
|
||||
| `Select` | `@/components/ui/Select` | 所有下拉框 |
|
||||
| `Badge` | `@/components/ui/Badge` | 所有标签徽章 |
|
||||
|
||||
### Toast 使用
|
||||
```tsx
|
||||
import { useToast, ToastContainer } from '@/components/ui/Toast'
|
||||
|
||||
const toast = useToast()
|
||||
toast.success('操作成功')
|
||||
toast.error('操作失败')
|
||||
|
||||
// JSX 中添加容器
|
||||
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、色彩系统
|
||||
|
||||
### 文本颜色
|
||||
|
||||
| 用途 | Tailwind 类 |
|
||||
|------|-------------|
|
||||
| 主文本 | `text-apple-label-primary` |
|
||||
| 次文本 | `text-apple-label-secondary` |
|
||||
| 辅助文本 | `text-apple-label-tertiary` |
|
||||
|
||||
### 背景颜色
|
||||
|
||||
| 用途 | Tailwind 类 |
|
||||
|------|-------------|
|
||||
| 主背景 | `bg-apple-bg-primary` |
|
||||
| 次级背景 | `bg-apple-bg-secondary` |
|
||||
| 三级背景 | `bg-apple-bg-tertiary` |
|
||||
|
||||
### 品牌色
|
||||
|
||||
| 模式 | 颜色 | Tailwind 类 |
|
||||
|------|------|-------------|
|
||||
| 浅色 | `#008D74` | `text-apple-brand` / `bg-apple-brand` |
|
||||
| 深色 | `#00B896` | `text-apple-brand` / `bg-apple-brand` |
|
||||
|
||||
---
|
||||
|
||||
## 三、圆角规范
|
||||
|
||||
| 大小 | 像素 | Tailwind 类 |
|
||||
|------|------|-------------|
|
||||
| 中 | 10px | `rounded-apple-md` |
|
||||
| 大 | 16px | `rounded-apple-lg` |
|
||||
|
||||
---
|
||||
|
||||
## 四、禁止事项
|
||||
|
||||
| 禁止 | 替代方案 |
|
||||
|------|---------|
|
||||
| `text-gray-*` | `text-apple-label-*` |
|
||||
| `bg-gray-*` | `bg-apple-bg-*` |
|
||||
| `shadow-*` | `shadow-apple-*` |
|
||||
| `rounded-lg/xl` | `rounded-apple-md/lg` |
|
||||
| `<button>` | `<Button>` |
|
||||
| `<input>` | `<Input>` |
|
||||
| 蓝色 focus ring | 品牌色 `#008D74` |
|
||||
|
||||
---
|
||||
|
||||
## 五、去 AI 味
|
||||
|
||||
| 禁止 | 推荐 |
|
||||
|------|------|
|
||||
| ❌ 均等分布(4个功能各占25%) | ✅ 非对称布局 |
|
||||
| ❌ 默认实心图标 | ✅ 细线条 stroke-width 1.5 |
|
||||
| ❌ 彩虹渐变背景 | ✅ 纯色/微妙渐变 |
|
||||
| ❌ Lorem Ipsum 占位 | ✅ 真实场景数据 |
|
||||
|
||||
---
|
||||
|
||||
## 六、动画规范
|
||||
|
||||
| 场景 | 时长 | 缓动函数 |
|
||||
|------|------|---------|
|
||||
| 展开 | 500ms | `cubic-bezier(0.32, 0.72, 0, 1)` |
|
||||
| 收起 | 350ms | `cubic-bezier(0.32, 0, 0.68, 0)` |
|
||||
| 按钮按压 | 100ms | `scale(0.98)` |
|
||||
| 悬停放大 | 200ms | `scale(1.02)` |
|
||||
|
||||
---
|
||||
|
||||
## 七、状态管理
|
||||
|
||||
### Zustand Stores
|
||||
```
|
||||
src/stores/
|
||||
├── auth.store.ts # 认证状态
|
||||
├── ui.store.ts # UI 状态
|
||||
└── product.store.ts # 产品状态
|
||||
```
|
||||
|
||||
### React Query Hooks
|
||||
```
|
||||
src/hooks/api/
|
||||
├── useProducts.ts
|
||||
├── useSKUs.ts
|
||||
└── useUsers.ts
|
||||
```
|
||||
|
||||
### 禁止
|
||||
```typescript
|
||||
// ❌ 禁止组件内直接 fetch
|
||||
const [data, setData] = useState()
|
||||
useEffect(() => { fetch('/api/xxx').then(...) }, [])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、目录结构
|
||||
|
||||
```
|
||||
src/components/
|
||||
├── ui/ # 元组件(必须复用)
|
||||
├── 3d/ # 3D 组件
|
||||
└── [feature]/ # 功能组件
|
||||
|
||||
src/app/
|
||||
├── (main)/ # 主布局页面
|
||||
├── api/ # API 路由
|
||||
└── share/ # 分享页面
|
||||
```
|
||||
113
.cursor/rules/performance.mdc
Normal file
113
.cursor/rules/performance.mdc
Normal file
@ -0,0 +1,113 @@
|
||||
---
|
||||
description: 性能优化师规范 - 检查性能问题
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 性能优化师规范
|
||||
|
||||
## 一、数据库性能
|
||||
|
||||
### 检查项
|
||||
- [ ] 是否存在 N+1 查询问题?
|
||||
- [ ] 是否使用了合理的索引?
|
||||
- [ ] 大数据量是否分页加载?
|
||||
|
||||
### N+1 查询示例
|
||||
```typescript
|
||||
// ❌ N+1 问题
|
||||
const products = await prisma.product.findMany()
|
||||
for (const product of products) {
|
||||
const skus = await prisma.sKU.findMany({ where: { productId: product.id } })
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 include
|
||||
const products = await prisma.product.findMany({
|
||||
include: { skus: true }
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、前端性能
|
||||
|
||||
### 检查项
|
||||
- [ ] 是否使用了 React Query 缓存?
|
||||
- [ ] 大列表是否使用虚拟滚动?
|
||||
- [ ] 图片是否懒加载?
|
||||
|
||||
### React Query 缓存
|
||||
```typescript
|
||||
const { data } = useQuery({
|
||||
queryKey: ['products', filters],
|
||||
queryFn: () => fetchProducts(filters),
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、3D 性能
|
||||
|
||||
### 检查项
|
||||
- [ ] 模型是否使用 LOD 策略?
|
||||
- [ ] 组件卸载时是否清理资源?
|
||||
- [ ] 是否避免内存泄漏?
|
||||
|
||||
### 内存泄漏检查
|
||||
- 事件监听器是否移除?
|
||||
- 定时器是否清除?
|
||||
- GSAP 动画是否停止?
|
||||
- geometry/material/texture 是否释放?
|
||||
|
||||
---
|
||||
|
||||
## 四、性能监控
|
||||
|
||||
### 请求计时
|
||||
```typescript
|
||||
const result = await timeAsync('product.import', async () => {
|
||||
// 耗时操作
|
||||
})
|
||||
```
|
||||
|
||||
### 日志规范
|
||||
```typescript
|
||||
logger.info('Product created', {
|
||||
productId: product.id,
|
||||
userId: ctx.user.id,
|
||||
duration: elapsed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、性能检查清单
|
||||
|
||||
| 类别 | 检查项 |
|
||||
|------|--------|
|
||||
| 数据库 | N+1 查询、索引、分页 |
|
||||
| 前端 | React Query 缓存、虚拟滚动、懒加载 |
|
||||
| 3D | LOD、资源清理、内存泄漏 |
|
||||
| 监控 | 请求计时、日志记录 |
|
||||
|
||||
---
|
||||
|
||||
## 六、审查报告格式
|
||||
|
||||
```
|
||||
## 性能审查结果
|
||||
|
||||
### 通过项 ✅
|
||||
- 使用了 React Query 缓存
|
||||
- 使用了分页加载
|
||||
|
||||
### 优化建议 ⚠️
|
||||
- 第 X 行存在 N+1 查询
|
||||
→ 修复:使用 include 预加载关联数据
|
||||
|
||||
### 严重问题 ❌
|
||||
- 组件卸载时未清理资源
|
||||
→ 修复:添加 dispose() 方法
|
||||
|
||||
### 评分:X/10
|
||||
```
|
||||
101
.cursor/rules/security.mdc
Normal file
101
.cursor/rules/security.mdc
Normal file
@ -0,0 +1,101 @@
|
||||
---
|
||||
description: 安全审查员规范 - 检查安全漏洞
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 安全审查员规范
|
||||
|
||||
## 一、输入验证
|
||||
|
||||
### 检查项
|
||||
- [ ] 所有用户输入是否经过 Zod 验证?
|
||||
- [ ] 文件上传是否检查类型和大小限制?
|
||||
- [ ] 是否防止了路径遍历攻击?
|
||||
|
||||
### 文件上传安全
|
||||
```typescript
|
||||
const MAX_TOTAL_SIZE = 500 * 1024 * 1024 // 500MB
|
||||
const MAX_FILE_COUNT = 100
|
||||
const MAX_SINGLE_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||
|
||||
// 解压前必须检查
|
||||
let totalSize = 0
|
||||
let fileCount = 0
|
||||
for (const [name, file] of Object.entries(zip.files)) {
|
||||
if (!file.dir) {
|
||||
fileCount++
|
||||
if (fileCount > MAX_FILE_COUNT) throw new Error('文件数量超限')
|
||||
if (size > MAX_SINGLE_FILE_SIZE) throw new Error('单文件过大')
|
||||
totalSize += size
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、权限检查
|
||||
|
||||
### 检查项
|
||||
- [ ] API 是否验证用户身份?
|
||||
- [ ] 是否检查用户对资源的操作权限?
|
||||
- [ ] 敏感操作是否有二次确认?
|
||||
|
||||
### 示例
|
||||
```typescript
|
||||
// 检查用户是否有权操作该资源
|
||||
this.checkPermission(entity, ctx.user)
|
||||
|
||||
// 如果没有权限
|
||||
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、常见漏洞
|
||||
|
||||
### SQL 注入
|
||||
- **检查**:是否使用 Prisma 参数化查询
|
||||
- **禁止**:字符串拼接 SQL
|
||||
|
||||
### XSS
|
||||
- **检查**:React 自动转义 + DOMPurify 清理 HTML
|
||||
- **禁止**:使用 `dangerouslySetInnerHTML` 未经清理
|
||||
|
||||
### CSRF
|
||||
- **检查**:Next.js CSRF Token 保护
|
||||
- **禁止**:跨域请求不带凭证
|
||||
|
||||
---
|
||||
|
||||
## 四、安全检查清单
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| 身份验证 | API 是否验证 JWT/Session |
|
||||
| 资源权限 | 用户是否有权操作该资源 |
|
||||
| 输入验证 | 所有输入是否经过 Zod 验证 |
|
||||
| 文件上传 | 是否检查类型、大小、路径 |
|
||||
| SQL 注入 | 是否使用参数化查询 |
|
||||
| XSS | 是否转义用户输入 |
|
||||
| CSRF | 是否有 Token 保护 |
|
||||
|
||||
---
|
||||
|
||||
## 五、审查报告格式
|
||||
|
||||
```
|
||||
## 安全审查结果
|
||||
|
||||
### 通过项 ✅
|
||||
- 使用了 Zod 验证输入
|
||||
- 使用了 Prisma 参数化查询
|
||||
|
||||
### 风险项 ⚠️
|
||||
- 文件上传未检查路径遍历(第 X 行)
|
||||
→ 修复:添加路径检查,禁止 .. 和绝对路径
|
||||
|
||||
### 严重漏洞 ❌
|
||||
- 无
|
||||
|
||||
### 评分:X/10
|
||||
```
|
||||
91
.cursor/rules/testing.mdc
Normal file
91
.cursor/rules/testing.mdc
Normal file
@ -0,0 +1,91 @@
|
||||
---
|
||||
description: QA 测试员规范 - 单元测试、集成测试
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# QA 测试员规范
|
||||
|
||||
## 一、测试类型
|
||||
|
||||
| 类型 | 用途 | 位置 |
|
||||
|------|------|------|
|
||||
| 单元测试 | 测试独立函数和组件 | `tests/` |
|
||||
| 集成测试 | 测试 API 端到端流程 | `tests/` |
|
||||
| 边界测试 | 测试异常输入和边界条件 | `tests/` |
|
||||
|
||||
---
|
||||
|
||||
## 二、测试框架
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| Jest | 测试框架 |
|
||||
| @testing-library/react | React 组件测试 |
|
||||
|
||||
---
|
||||
|
||||
## 三、测试重点
|
||||
|
||||
### 必测项
|
||||
- 核心业务逻辑
|
||||
- 错误处理路径
|
||||
- 权限检查逻辑
|
||||
|
||||
### 示例
|
||||
```typescript
|
||||
describe('ProductService', () => {
|
||||
it('should create product successfully', async () => {
|
||||
const result = await productService.create(validData)
|
||||
expect(result).toBeDefined()
|
||||
expect(result.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('should throw error when product not found', async () => {
|
||||
await expect(
|
||||
productService.getById('non-existent-id')
|
||||
).rejects.toThrow(AppError)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、常用命令
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm test
|
||||
|
||||
# 运行单个文件
|
||||
npm test -- tests/lib/zip-parser.test.ts
|
||||
|
||||
# 运行单个用例
|
||||
npm test -- tests/lib/zip-parser.test.ts -t "用例名"
|
||||
|
||||
# 串行运行
|
||||
npm test -- --runInBand tests/lib/zip-parser.test.ts
|
||||
|
||||
# 覆盖率
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、测试原则
|
||||
|
||||
1. **AAA 模式**:Arrange(准备)→ Act(执行)→ Assert(断言)
|
||||
2. **单一职责**:每个测试只验证一个行为
|
||||
3. **独立性**:测试之间互不影响
|
||||
4. **可读性**:测试名称清晰描述预期行为
|
||||
|
||||
---
|
||||
|
||||
## 六、测试目录结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── lib/ # 工具函数测试
|
||||
├── services/ # Service 层测试
|
||||
├── repositories/ # Repository 层测试
|
||||
└── integration/ # 集成测试
|
||||
```
|
||||
@ -75,6 +75,7 @@ dist
|
||||
|
||||
# Gatsby文件
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Vuepress构建输出
|
||||
.vuepress/dist
|
||||
@ -224,4 +225,4 @@ PROJECT_SETUP_COMPLETE.md
|
||||
产品安装展示系统-*.md
|
||||
开发部署计划.md
|
||||
项目*.md
|
||||
架构*.md
|
||||
架构*.md
|
||||
37
.github/copilot-instructions.md
vendored
37
.github/copilot-instructions.md
vendored
@ -1,37 +0,0 @@
|
||||
# Copilot Instructions - 3D Furniture Workspace
|
||||
|
||||
Scope: Entire repository `D:\AI Code\3danzhuang`.
|
||||
|
||||
## Mandatory Reading Order
|
||||
Before coding, always read these files in order:
|
||||
|
||||
1. `AGENTS.md`
|
||||
2. `furniture-3d-system/.cursor/rules/core-standards.mdc`
|
||||
3. `furniture-3d-system/.cursor/rules/backend.mdc` or `furniture-3d-system/.cursor/rules/frontend.mdc` (pick by task type)
|
||||
4. `furniture-3d-system/.cursor/rules/checklist.mdc`
|
||||
5. `furniture-3d-system/docs/设计规范/苹果风格视觉标准.md` (for UI tasks)
|
||||
|
||||
## Non-Negotiable Backend Rules
|
||||
- Enforce architecture: `API Route -> Service -> Repository -> Database`.
|
||||
- Never put Prisma business queries directly in API routes.
|
||||
- Use unified request/response helpers: `handleRequest`, `jsonSuccess`, `jsonError`.
|
||||
- Use unified errors: `AppError + ErrorCode`.
|
||||
|
||||
## Non-Negotiable Frontend Rules
|
||||
- Reuse base UI components from `src/components/ui/` first.
|
||||
- Follow Apple-style design tokens and visual system.
|
||||
- Keep brand colors fixed: `#008D74` (light) and `#00B896` (dark).
|
||||
- Avoid `text-gray-*`, `bg-gray-*`, and raw `<button>`, `<input>`, `<select>` unless explicitly required.
|
||||
|
||||
## Working Directory
|
||||
- If user does not specify, default to `furniture-3d-system`.
|
||||
|
||||
## Delivery Gate
|
||||
Before final response, include:
|
||||
- Changed file list
|
||||
- Validation commands executed
|
||||
- Validation results
|
||||
|
||||
## Execution Policy
|
||||
- Do not optimize for "minimal changes" by default.
|
||||
- Prefer complete, verifiable fixes for the full task chain.
|
||||
106
.gitignore
vendored
106
.gitignore
vendored
@ -1,57 +1,61 @@
|
||||
# BitFun snapshot data - auto managed
|
||||
.bitfun/
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# IDE / Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
.trae/
|
||||
.kiro/
|
||||
.cursorindexingignore
|
||||
.cursorrules
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# OS
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.pem
|
||||
|
||||
# Logs
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# local workspace artifacts
|
||||
.codex-logs/
|
||||
_logs/
|
||||
|
||||
# Temp
|
||||
output/
|
||||
temp/
|
||||
*.tmp
|
||||
/.tmp-*/
|
||||
/tmp-*/
|
||||
/tmp-*.html
|
||||
/tmp-*.png
|
||||
/tmp-*.env
|
||||
/tmp-*.service
|
||||
/tmp-*.timer
|
||||
/tmp-*.err
|
||||
/tmp-*.out
|
||||
/.gemini/
|
||||
|
||||
# Build artifacts (C# / .NET)
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
# Build / Deploy temp
|
||||
deploy-package/
|
||||
|
||||
# Screenshots (local only)
|
||||
截图/
|
||||
|
||||
# Duplicate / backup files
|
||||
* - 副本.md
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# === 大文件(保留在本地,不提交到 git)===
|
||||
local-packages/
|
||||
public/materials/pbr/
|
||||
素材/
|
||||
方案设定/
|
||||
autocad-plugin/dist/
|
||||
autocad-plugin/installer/FurnitureExporter.bundle/Contents/
|
||||
public/uploads/
|
||||
*.vsix
|
||||
nul
|
||||
%OUT%
|
||||
.dev-*.log
|
||||
.qa-*.log
|
||||
.start-*.log
|
||||
.next-start-*.log
|
||||
dev-server.log
|
||||
trace-deprecation-build.log
|
||||
typecheck-latest.log
|
||||
local-packages/npm-cache/cache/_logs/
|
||||
|
||||
21
.kiro/steering/project-preferences.md
Normal file
21
.kiro/steering/project-preferences.md
Normal file
@ -0,0 +1,21 @@
|
||||
# 开发规范
|
||||
|
||||
## 🗣️ 交流语言
|
||||
**重要**: 始终使用中文与用户交流
|
||||
- 所有回复和解释用中文
|
||||
- 错误信息和建议用中文
|
||||
- 代码注释用中文
|
||||
- 文档说明用中文
|
||||
|
||||
## 📋 项目信息
|
||||
- **名称**: 3D家具安装展示系统
|
||||
- **技术**: Next.js + Three.js + Apple UI
|
||||
- **目标**: 现代化3D家具装配展示
|
||||
|
||||
## ⚡ 开发原则
|
||||
- TypeScript 严格模式
|
||||
- Apple HIG 2025 设计标准
|
||||
- 性能优先 (React.memo + 懒加载)
|
||||
- 代码整洁 (精简文档,避免冗余)
|
||||
- 不重要的东西不要写文档
|
||||
- 能在原有文档上更新就采用更新的方式
|
||||
167
AGENTS.md
167
AGENTS.md
@ -1,167 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
适用范围:`D:\AI Code\3danzhuang-wuyongjie-mainline` 全仓库。
|
||||
未单独说明时,先遵循当前 agent 自己的全局规则(ZCode:`C:\Users\54972\.zcode\v2\AGENTS.md`;Codex:`C:\Users\54972\.codex\AGENTS.md`)。
|
||||
这份文件只记录本仓库当前有效的补充规则和核查入口,不保存密码、密钥、数据库连接串或对象存储密钥。
|
||||
|
||||
重要边界:`D:\AI Code\3danzhuang` 是历史旧现场;当前正式工作目录以 `D:\AI Code\3danzhuang-wuyongjie-mainline` 为准。除非用户明确要求,不要混用旧目录、旧服务器别名、旧部署脚本或旧截图结论。
|
||||
|
||||
## 1. 当前项目真相
|
||||
|
||||
- 外层目录 `D:\AI Code\3danzhuang-wuyongjie-mainline` 是 Git 仓库根目录。
|
||||
- 主应用目录是 `furniture-3d-system/`;用户未指定目录时,默认在这里执行代码、测试、构建和脚本。
|
||||
- 技术栈以当前 `furniture-3d-system/package.json` 为准(核心:Next.js App Router + React + TypeScript + Prisma/PostgreSQL + Jest + Playwright + Tailwind CSS + Three.js/R3F;另有 ioredis、puppeteer、exceljs/xlsx、zod、zustand 等)。package.json 变动后文档不自动同步,以实时核查为准。
|
||||
- `CLAUDE.md`、旧 README、`docs/历史归档/` 和早期部署文档里可能仍有旧苹果风格、MinIO、旧 Docker 部署、旧模型数量、旧 Product/SKU 口径等信息;只能当历史参考,不能覆盖当前代码、workflow 文档和实时命令结果。
|
||||
- 工作树可能有用户或其他协作者留下的未跟踪文件、日志、贴图和本地产物。只处理本次任务明确相关的文件,不要顺手清理或回退无关现场。
|
||||
|
||||
## 2. 开工前固定入口
|
||||
|
||||
当任务发生在 `furniture-3d-system/` 时,改代码前必须先读这 3 份当前协作入口:
|
||||
|
||||
- `furniture-3d-system/docs/workflow/团队协作模式.md`
|
||||
- `furniture-3d-system/docs/workflow/模块边界说明.md`
|
||||
- `furniture-3d-system/docs/workflow/主线同步操作手册.md`
|
||||
|
||||
按任务类型额外读取:
|
||||
|
||||
- 生产、部署、线上排障:`furniture-3d-system/docs/workflow/生产部署入口说明.md`,并核对 `scripts/deploy-yydd-prod-from-git.ps1` 与其调用的 `scripts/create-yydd-prod-deploy.ps1` 当前默认参数。
|
||||
- 前端、视觉、交互:`furniture-3d-system/docs/设计规范/白曜石工坊统一视觉标准.md` 和 `furniture-3d-system/docs/workflow/规范审查清单.md`。
|
||||
- 包装、仓库、轻仓库协作:再读包装/仓库相关交接文档和 `src/app/warehouse`、`src/app/api/warehouse`、`src/app/api/pre-skus/[id]/packaging/route.ts` 的当前实现。
|
||||
- 数据库、主数据、迁移:优先读 `docs/架构设计/内部主数据巡检基线-20260529.md`、`docs/架构设计/数据库混乱点治理台账.md` 和相关 `db:foundation:*` 脚本说明。
|
||||
|
||||
如果文档和代码冲突,按这个顺序判断:当前代码和脚本、当前线上/命令返回、当前 workflow 文档、历史文档或旧记忆。
|
||||
|
||||
## 3. Git 与主线协作
|
||||
|
||||
- `main` 是最终发布主线,由 `WuYongJie` 收口;不要直接把半成品推到 `main`。
|
||||
- `origin/codex/xuxiake-mainline` 是日常共享开发主线和默认同步基线。
|
||||
- 新功能、优化和修复默认从最新 `origin/codex/xuxiake-mainline` 开任务分支,再按协作流程回流。
|
||||
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`,不要再新建 `feature/...`、`packaging/...`、`dev`、`codex/xuxiake-dev` 这类泛分支名。
|
||||
- 长期共享分支同步共享开发主线时固定使用 `merge origin/codex/xuxiake-mainline`;只有确认无人共用的临时个人任务分支才使用 `rebase origin/codex/xuxiake-mainline`。
|
||||
- 每次开工或接续旧任务时,先看 `git status --short --branch`;涉及主线同步时再看 `git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline`,不要把“当前所在分支”误当成“已同步共享主线”。
|
||||
- 用户问“xuxiake 主线有没有新提交”时,必须先 `git fetch origin`,再基于最新远端状态回答。
|
||||
- 不要在脏工作树上直接 merge/rebase。先看 `git status --short`,分清本次改动、用户改动、本地产物和无关未跟踪文件。
|
||||
- 常规任务完成必要验证后,可以按协作流程推进 `commit / push`;`main`、生产发布、破坏性操作、删除数据、改远端历史等高风险动作必须有用户明确授权。
|
||||
|
||||
### 主线部署标准流程(每次"主线部署"都走这一套)
|
||||
|
||||
本仓库有两根主线,都不直接提交:`main` 是生产部署唯一来源(由 `WuYongJie` 收口),`origin/codex/xuxiake-mainline` 是共享开发次主线(日常回流基线)。
|
||||
|
||||
每次用户要求"主线部署",严格按以下顺序,不跳步、不直接支线 → main:
|
||||
|
||||
1. **在支线开发** → 提交到任务分支(如 `codex/wuyongjie-xxx`),不碰两根主线。
|
||||
2. **fetch xuxiake** → 必须先 `git fetch origin`,看 `origin/codex/xuxiake-mainline` 有没有新提交。
|
||||
3. **有新提交** → `merge origin/codex/xuxiake-mainline` 拉下来处理冲突(长期共享分支用 merge,不 rebase);只处理冲突文件,不扩范围。
|
||||
4. **合并到 xuxiake** → 把支线合进 `origin/codex/xuxiake-mainline`,并 `push` 更新次主线。
|
||||
5. **合并到 main** → 把 xuxiake 合进 `main`,并 `push origin main`(本步需用户授权)。
|
||||
6. **用 main 部署** → 走 `scripts/deploy-yydd-prod-from-git.ps1`(默认源 `origin/main`),部署后核对 commit / 进程 / 端口 / 日志 / 关键页面(本步需用户授权)。
|
||||
|
||||
发布前必须确认 `git ls-remote origin refs/heads/main`、本地 `origin/main`、目标部署 commit 三者一致。
|
||||
|
||||
## 4. 生产与部署
|
||||
|
||||
- 当前正式网址:`https://cp.jiansun.vip/`。
|
||||
- 当前权威生产 SSH 入口:`root@ltai5tdb2ogyj59fquyedh.yydd.ai`,端口 `60022`。
|
||||
- 当前生产部署目录:`/opt/furniture-3d-system-prod`。
|
||||
- 当前 PM2 进程名:`furniture-3d-system-prod`。
|
||||
- 当前应用本机端口:`3001`。
|
||||
- 默认生产部署来源:`origin/main`。
|
||||
- 当前主部署入口是外层 `scripts/deploy-yydd-prod-from-git.ps1`;它默认部署 `origin/main`,并调用 `scripts/create-yydd-prod-deploy.ps1` 生成发布包/远端发布脚本。不要把下层打包脚本单独当成唯一部署入口。
|
||||
- 发布前必须确认 `git ls-remote origin refs/heads/main`、本地 `origin/main`、目标部署 commit 三者一致;本地 `main` 是否领先或落后远端不能替代 `origin/main` 的真实发布来源。
|
||||
- 旧 `192.168.58.20`、本机泛别名 `prod`、旧 `gongxiangp2 / youyidian-3d-material` 生产信息都不能套用到本项目。
|
||||
- 生产动作前至少实时确认:目标主机、远端目录、PM2 进程、`current` 软链接、监听端口和 `https://cp.jiansun.vip/api/health`。
|
||||
- 生产数据库、迁移、主数据地基相关动作先只读核查;需要写入时必须有备份、目标库身份、确认口令和可回滚路径。
|
||||
- 对象存储当前要按 `src/lib/storage.ts`、`storage:*` 脚本、七牛迁移/GC 文档和生产环境变量实时核对;不要把历史 MinIO/S3 备份脚本当成当前生产存储真相。
|
||||
|
||||
## 5. 当前主链与模块边界
|
||||
|
||||
当前新平台数据主链按这条线理解:
|
||||
|
||||
`资产中心 -> 辅助装配 -> 草稿组 -> 产品资料/正式入库 -> 仓库管理/轻仓库`
|
||||
|
||||
当前全局工作台导航还包含首页、OA 新品流程看板、仓库质检、AI 出图工作台等运营入口。导航入口不等于数据主链,不要把页面入口和主数据归属混成一件事。
|
||||
|
||||
主链默认入口和目录:
|
||||
|
||||
- 首页与全局壳:`src/app/page.tsx`、`src/components/layout`
|
||||
- 资产中心:`src/app/asset-center`、`src/app/api/asset-center`
|
||||
- 辅助装配:`src/app/assisted-assembly`、`src/app/api/assisted-assembly`
|
||||
- 草稿组:`src/app/draft-groups`、`src/app/api/draft-groups`
|
||||
- 产品资料/正式入库:`src/app/intake`、`src/app/api/intake`
|
||||
- 仓库管理/轻仓库:`src/app/warehouse`、`src/app/api/warehouse`
|
||||
- 仓库质检:`src/app/warehouse-quality`、`src/app/api/warehouse-quality`
|
||||
- AI 出图工作台:`src/app/ai-render-workbench`
|
||||
- 审批工作流:`src/app/approval-workflows`
|
||||
- 素材库:`src/app/materials`
|
||||
- 移动端:`src/app/mobile`
|
||||
- 平台组件库:`src/components/platform`
|
||||
- 主链规则与类型:`src/lib/open-product`、`src/lib/planning`
|
||||
|
||||
业务真相:
|
||||
|
||||
- 资产族:`AssetFamily`
|
||||
- 规格款:`AssetVariant`
|
||||
- 草稿组:`DraftGroup`
|
||||
- 准 SKU:`PreSku`
|
||||
- 入库结果:正式 `Product / ProductSKU`(`SKU` 是业务概念,Prisma 模型名是 `ProductSKU`)
|
||||
- 产品资料侧非集成开品:`IntakeNonIntegratedProduct / IntakeNonIntegratedSkuItem`
|
||||
|
||||
旧 CAD 兼容链继续存在,但不是新平台主数据源:
|
||||
|
||||
- 页面:`src/app/products`、`src/app/products/[id]`、`src/app/products/[id]/assembly`、`src/app/products/[id]/skus/[skuId]`
|
||||
- 旧链模型和服务包括 `Product / ProductSKU / Panel / Hole / ProductHardware`、`assembly.service`、`panel.service`、`hole.service` 等。
|
||||
- 旧链可以继续服务历史产品、CAD 导入、装配步骤和兼容发布后能力,但不要反向主导资产中心、草稿组或产品资料主链。
|
||||
|
||||
共享接缝默认谨慎处理:
|
||||
|
||||
- `prisma/schema.prisma`
|
||||
- `prisma/migrations`
|
||||
- `src/app/layout.tsx`
|
||||
- `src/app/intake`
|
||||
- `src/app/draft-groups/[id]`
|
||||
- `src/app/api/platform/schemas.ts`
|
||||
- `src/server/services/draft-group.service.ts`
|
||||
- `src/server/services/intake.service.ts`
|
||||
- `src/server/repositories/draft-group.repository.ts`
|
||||
|
||||
改这些地方前先说明原因、影响范围和验证计划;不要为了局部 UI 或临时兼容绕过模块边界。
|
||||
|
||||
## 6. 架构硬规则
|
||||
|
||||
- API Route 负责收参、鉴权和返回,不直接堆业务规则或 Prisma 查询。
|
||||
- Service 负责业务规则、权限校验、事务编排、缓存和跨模块协调。
|
||||
- Repository 负责数据访问。
|
||||
- 任何新接口必须有明确输入输出 DTO/校验口径。
|
||||
- 不是必须的话,不要使用兜底逻辑;原生主逻辑能做好,就先把主逻辑、状态契约和数据来源修正确。兜底只能用于明确的兼容、降级或应急场景,不能用来掩盖本应修好的业务规则、接口契约或前端状态问题。
|
||||
- 任何 Prisma 模型调整必须带迁移,并补齐主数据/迁移核查;生产/准生产禁止用 `prisma db push` 替代迁移。
|
||||
- 影响命名、BOM、包装、安装步骤、SKU、barcode、批次、入库承接的数据问题,优先从数据源和业务规则修,不在页面上压报错或绕校验。
|
||||
- SPU、SKU、barcode、批次不能混用:SPU 是父级产品款,SKU 是 SPU 下的具体子项(Prisma 模型名 `ProductSKU`),barcode 是绑定到最终 SKU 的业务定位值;基于某 SKU 派生的新 SKU 必须仍属于同一 SPU,并按业务要求继承或明确指定批次。
|
||||
|
||||
## 7. 前端与视觉硬规则
|
||||
|
||||
- 白曜石工坊统一视觉标准是本项目新平台第一视觉标准;默认是浅色工作台,不再提供深浅色主题切换入口。
|
||||
- 本项目有自己的组件库与 `Meta Component(元组件)` 体系;前端实现必须优先复用 `src/components/platform`、资产中心已沉淀的工作台表达和现有元组件,不允许绕开组件库另起一套孤立 UI。
|
||||
- 当用户指定 Stitch 设计稿或已有 Stitch 设计稿可参考时,Stitch 是当前功能的视觉规范与验收标准;实现方式是在本项目组件库/元组件体系内对齐 Stitch 的密度、层级、控件形态、留白、字号、圆角、阴影和低噪音表达,而不是无视组件库硬抄静态稿。
|
||||
- 资产中心页面是当前项目内的优先参考样板;涉及资产、图片、SKU、AI 白底图、3D 预览、工作区和检视器时,默认参考资产中心的工作台感、元组件表达、低噪音操作面板和重点图片区。
|
||||
- `Inspector(检视器)`、数据列表、表格、长文本和表单密集区必须优先保证可读性;毛玻璃只允许用于顶栏、HUD、浮动工具条和轻量状态浮层,不承载正文。
|
||||
- 板材、五金、组件、包装等资产必须按元组件表达,不能退回普通图片卡片或营销式大卡片布局。
|
||||
- 现有 `apple-*` token、深色 `ObsidianControlShell`、历史主题 CSS 只能作为兼容遗留;新功能不得以它们作为视觉方向。
|
||||
- 前端改造不得把现有后台习惯、普通 SaaS 审美或共享组件默认皮肤当成最终依据;如果组件库默认视觉和 Stitch/资产中心标准冲突,应在组件库体系内做专用变体、重写皮肤或局部重构。
|
||||
- 没有出现在设计稿、组件规范或业务必要路径里的说明文案、辅助卡片、重复统计块、解释区,不要为了“更易懂”自行添加;工作区必须抓重点,图片类功能要让图片和关键动作成为第一层级。
|
||||
- 涉及前端动画、入退场、滚动联动、时间线、拖拽、SVG 或动效性能时,先判断是否有助于理解和操作;需要时优先按技术栈调用对应 GSAP Skill,并继续遵守 Impeccable。React 必须使用 `useGSAP` 或带 `gsap.context().revert()` 的完整清理,滚动相关实例必须销毁并在布局变化时正确刷新;优先动画 `transform/opacity`,尊重 `prefers-reduced-motion`,不得为装饰滥用动效。
|
||||
- 前端修改后优先用真实页面、截图、1920×1080 视口和必要的移动端视口验证,不只看代码。
|
||||
|
||||
## 8. 验证、收口与规范审查
|
||||
|
||||
- 修改后必须做最小但有效的验证;不能验证就明确说明未验证原因和风险。
|
||||
- 常用验证命令以当前 `package.json` 为准,优先选和改动范围匹配的最小集合:`npm run type-check`、`npm run build`、`npm test -- <相关测试>`、`npm run lint`;对象存储用 `npm run storage:gc:audit` / `npm run storage:gc:apply`,主数据用 `npm run db:foundation:*` 相关只读或授权脚本。
|
||||
- 前端体验类改动要结合浏览器实际操作和截图验证;涉及 3D/canvas 时要确认画面非空、尺寸正确、交互可用。
|
||||
- 生产验证必须包含真实健康检查和日志/PM2/端口等现场证据,不能只说"本地构建通过"。
|
||||
- 规范审查固定只查三件事:架构有没有被破坏、视觉有没有跑偏、这次是不是根因修复。结论只能是 `passed` / `passed with warnings` / `rejected`;只要架构违规、视觉违规或明显补丁式修复,就必须 `rejected`。非独立复审时必须写明"这是自审,不是独立复审"。统一清单:`furniture-3d-system/docs/workflow/规范审查清单.md`。没有规范审查结论,任务不算完成。
|
||||
|
||||
## 9. 敏感信息和交接
|
||||
|
||||
- 账号密码、访问密钥、数据库连接串、对象存储密钥等敏感信息只能放在本地安全材料里,不写入 Git、共享文档、普通交接文本或长期记忆;生产域名、PM2 名称、部署目录这类非密钥运行入口可以写进仓库文档,但必须保持最新。
|
||||
- 仓库内协作文档只写非敏感入口、流程和职责;凭据类信息必须用 `[REDACTED_SECRET]` 或本地路径说明替代。
|
||||
- 发现摘要、日志或交接文件混有明文凭据时,先提示风险,再决定是否拆成本地敏感版和仓库协作版。
|
||||
- 给下一个 agent 或同事交接时,写清:本次只改了什么、没改什么、验证跑了什么、风险是什么、下一步要看哪些文件。
|
||||
86
CLAUDE.md
86
CLAUDE.md
@ -1,86 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
3D 家具安装展示系统 — 面向电商企业的家具产品管理平台,核心能力是 3D 交互式装配动画展示。主代码在 `furniture-3d-system/` 目录。
|
||||
|
||||
## 常用命令
|
||||
|
||||
所有命令在 `furniture-3d-system/` 目录下执行:
|
||||
|
||||
```bash
|
||||
npm run dev # 启动开发服务器
|
||||
npm run build # 构建(prisma generate + next build)
|
||||
npm run lint # ESLint 检查
|
||||
npm run type-check # TypeScript 类型检查(next typegen + tsc --noEmit)
|
||||
npm test # 运行 Jest 测试
|
||||
npm test -- --testPathPattern="文件名" # 运行单个测试
|
||||
npm run test:coverage # 带覆盖率测试(阈值 70%)
|
||||
npm run prisma:generate # 生成 Prisma Client
|
||||
npm run prisma:push # 推送数据库 schema
|
||||
npm run prisma:studio # 打开 Prisma Studio
|
||||
npm run demo:setup # 初始化演示数据(push + seed)
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**:Next.js 16 (App Router) + React 19 + TypeScript 5 + Tailwind CSS 4
|
||||
- **3D**:Three.js + React Three Fiber + Drei + GSAP
|
||||
- **状态**:Zustand
|
||||
- **后端**:Next.js API Routes + Prisma 7 (PostgreSQL) + Redis (ioredis) + MinIO (S3)
|
||||
- **认证**:JWT + bcryptjs
|
||||
- **测试**:Jest 30 + React Testing Library
|
||||
- **路径别名**:`@/*` → `src/*`
|
||||
|
||||
## 架构(硬约束)
|
||||
|
||||
### 后端分层(禁止跳层)
|
||||
|
||||
```
|
||||
API Route → Service → Repository → Database (Prisma)
|
||||
```
|
||||
|
||||
- **API Route**(`src/app/api/`):只做参数接收、鉴权、调用 Service、统一响应
|
||||
- **Service**(`src/server/services/`):权限校验、业务规则、缓存、事务编排
|
||||
- **Repository**(`src/server/repositories/`):纯数据访问
|
||||
- **统一错误**:`AppError + ErrorCode`(`src/server/core/`)
|
||||
- **统一响应**:`handleRequest`、`jsonSuccess`、`jsonError`(`src/server/core/`)
|
||||
- **DTO**:`src/server/dto/`,**Validator**:`src/server/validators/`
|
||||
|
||||
### 前端约束
|
||||
|
||||
- **元组件优先**:必须复用 `src/components/ui/`(29 个基础组件),禁止手搓替代品
|
||||
- **苹果风格视觉标准**:设计令牌在 `src/lib/design-tokens/`
|
||||
- **品牌色**:`#008D74`(浅色)/ `#00B896`(深色)
|
||||
- **禁止**:`text-gray-*` / `bg-gray-*` / 原生 `<button>` `<input>` `<select>`
|
||||
- **状态管理**:`src/stores/`(auth/product/ui)
|
||||
- **业务组件**:按领域分组在 `src/components/`(3d/assembly/auth/products/panels/packaging/users 等)
|
||||
|
||||
### 数据库
|
||||
|
||||
Prisma schema 在 `furniture-3d-system/prisma/schema.prisma`,22 个模型。核心实体:Product → ProductSKU → AssemblyStep/AssemblyVersion → Panel → Hole → Hardware。
|
||||
|
||||
## 交付规范审查(必做)
|
||||
|
||||
任何开发改动,交付前必须做一次规范审查,固定只查三件事:
|
||||
|
||||
1. **架构**:是否遵循分层、统一错误/响应格式
|
||||
2. **视觉**:是否复用元组件、遵循设计令牌
|
||||
3. **根因修复**:是不是真正解决问题,而不是补丁式绕过
|
||||
|
||||
结论只能是:`passed` / `passed with warnings` / `rejected`。
|
||||
违规即 `rejected`。详细清单见 `docs/workflow/规范审查清单.md`。
|
||||
|
||||
## 规则优先级
|
||||
|
||||
用户当前明确指令 > 子目录规则(`.cursor/rules/*.mdc`)> `AGENTS.md` > 本文件
|
||||
|
||||
## 默认行为
|
||||
|
||||
- 用户未指定目录时,默认在 `furniture-3d-system/` 执行
|
||||
- 未经用户明确要求,不主动 commit / push
|
||||
- 中文沟通,面向非程序员,简短直白
|
||||
- 优先复用现有实现,不顺手改无关问题
|
||||
- 不以"最小改动"为默认目标,优先完成主需求闭环并收口同链路已发现问题
|
||||
@ -7,9 +7,10 @@
|
||||
# ============================================
|
||||
# 阶段 1: 基础镜像
|
||||
# ============================================
|
||||
FROM node:24-alpine AS base
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# 保留官方 Alpine 源,避免镜像站同步滞后导致 apk 包 404。
|
||||
# 使用阿里云镜像源加速(中国网络优化)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
# ============================================
|
||||
# 阶段 2: 依赖安装 (deps)
|
||||
@ -70,11 +71,6 @@ FROM base AS builder
|
||||
ARG DATABASE_URL="postgresql://user:password@localhost:5432/placeholder_db?schema=public"
|
||||
ARG NEXTAUTH_URL=""
|
||||
ARG NEXT_PUBLIC_APP_URL=""
|
||||
ARG S3_ENDPOINT="http://localhost:9000"
|
||||
ARG S3_PUBLIC_DOMAIN="http://localhost:9000/furniture-3d-models"
|
||||
ARG S3_BUCKET="furniture-3d-models"
|
||||
ARG S3_ACCESS_KEY="build-only"
|
||||
ARG S3_SECRET_KEY="build-only"
|
||||
|
||||
# Prisma 引擎缓存配置
|
||||
ARG PRISMA_ENGINE_CACHE_FILE_NAME="libquery_engine-linux-musl-openssl-3.0.x.so.node"
|
||||
@ -86,12 +82,6 @@ ENV PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME=${PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME}
|
||||
ENV DATABASE_URL=${DATABASE_URL}
|
||||
ENV NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
ENV S3_ENDPOINT=${S3_ENDPOINT}
|
||||
ENV S3_PUBLIC_DOMAIN=${S3_PUBLIC_DOMAIN}
|
||||
ENV S3_BUCKET=${S3_BUCKET}
|
||||
ENV S3_ACCESS_KEY=${S3_ACCESS_KEY}
|
||||
ENV S3_SECRET_KEY=${S3_SECRET_KEY}
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1
|
||||
@ -247,4 +237,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
|
||||
# 启动应用
|
||||
CMD ["node", "server.js"]
|
||||
CMD ["node", "server.js"]
|
||||
232
README.md
232
README.md
@ -1,47 +1,215 @@
|
||||
# 由壹点「产品开发平台」工作区
|
||||
# 3D 家具安装展示系统
|
||||
|
||||
> 最后更新:2026-04-07
|
||||
> 当前主线:`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 正式入库 -> 轻仓库`
|
||||
> 现代化的 3D 家具装配展示与管理系统,支持交互式装配动画、版本回溯与分享协作
|
||||
|
||||
这个工作区真正长期开发的项目在 `furniture-3d-system/`。
|
||||
旧 `CAD -> 装配编辑 -> 装配步骤 -> 回放` 链仍保留,但它现在是兼容链,不是默认主线。
|
||||
**项目状态**:生产基础能力已具备,当前进入结构化数据与装配体验持续收口阶段
|
||||
**最后更新**:2026-03-13
|
||||
|
||||
## 先看哪里
|
||||
---
|
||||
|
||||
1. `AGENTS.md`
|
||||
2. `furniture-3d-system/docs/workflow/团队协作模式.md`
|
||||
3. `furniture-3d-system/docs/workflow/模块边界说明.md`
|
||||
4. `furniture-3d-system/docs/workflow/主线同步操作手册.md`
|
||||
5. `furniture-3d-system/docs/README.md`
|
||||
## 🏗️ 企业级架构与统一视觉标准(强制)
|
||||
|
||||
## 工作区结构
|
||||
### 企业级架构(后端)
|
||||
本项目后端统一遵循:`API Route -> Service -> Repository -> Database`
|
||||
|
||||
```text
|
||||
.
|
||||
├── furniture-3d-system/ 主项目
|
||||
├── docs/ 工作区级协作与交接文档
|
||||
├── 方案设定/ 设计稿与方案素材
|
||||
├── flnxi_sucai/ 参考资料
|
||||
└── README.md
|
||||
```
|
||||
- API Route:只做参数接收、鉴权入口、调用 Service、统一返回
|
||||
- Service:负责权限校验、业务规则、缓存策略、DTO 组装、事务编排
|
||||
- Repository:只负责数据访问,不承载业务逻辑分支
|
||||
- 错误与响应:统一 `AppError + ErrorCode`、`handleRequest/jsonSuccess/jsonError`
|
||||
- 架构治理:优先做系统性收口,不做临时补丁式修补
|
||||
|
||||
## 当前文档规则
|
||||
### 统一视觉标准(前端)
|
||||
- UI 必须优先复用 `src/components/ui/` 元组件(Button/Input/Modal/Select/Toast 等)
|
||||
- 保持苹果风格视觉体系,遵循设计令牌,不随意改全局样式
|
||||
- 品牌色固定:`#008D74`(浅色)/ `#00B896`(深色)
|
||||
- 排版统一:`font-apple-system` + `tracking-apple-*`
|
||||
|
||||
- 当前有效的协作入口只认 `furniture-3d-system/docs/workflow/` 下那 3 份协作文档。
|
||||
- 根目录 `docs/前端UI改动清单.md` 是当前前端交接执行单,不是历史总结。
|
||||
- 一次性阶段报告、完成报告、旧 UI 方案已清理,不再保留为默认入口。
|
||||
### 规则文件(必须阅读)
|
||||
- `.cursor/rules/core-standards.mdc`
|
||||
- `.cursor/rules/backend.mdc`
|
||||
- `.cursor/rules/frontend.mdc`
|
||||
- `.cursor/rules/checklist.mdc`
|
||||
- `docs/设计规范/苹果风格视觉标准.md`
|
||||
|
||||
## 常用命令
|
||||
---
|
||||
|
||||
```powershell
|
||||
cd furniture-3d-system
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- PostgreSQL 14+
|
||||
- Redis(可选,推荐)
|
||||
- MinIO / S3 兼容对象存储(必需)
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
npm install
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,配置数据库与 MinIO/S3
|
||||
|
||||
# 3. 初始化数据库
|
||||
npx prisma migrate dev
|
||||
npx prisma db seed
|
||||
|
||||
# 4. 启动开发服务器
|
||||
npm run dev
|
||||
npm run type-check
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 补充说明
|
||||
访问 http://localhost:3000
|
||||
|
||||
- 非代码参考资料默认不参与构建。
|
||||
- 没有用户明确要求,不主动提交、推送、发版。
|
||||
### 本地依赖(Docker)
|
||||
|
||||
```bash
|
||||
# 启动 Postgres / Redis / MinIO
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# 如需 DWG 转换服务(可选,ODA 方案)
|
||||
docker-compose -f docker-compose.dev.yml --profile dwg build dwg-converter
|
||||
docker-compose -f docker-compose.dev.yml --profile dwg up -d dwg-converter
|
||||
```
|
||||
|
||||
### 生产部署
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
.\scripts\deploy.ps1
|
||||
|
||||
# Linux/Mac
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
更多细节见:
|
||||
- `docs/部署指南.md`
|
||||
- `docs/运维指南.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心功能(基于代码)
|
||||
|
||||
- **认证与权限**:JWT + Cookie、首次登录引导、RBAC 6 角色
|
||||
- **组织架构**:部门/小组/成员管理与归属
|
||||
- **产品与 SKU**:产品/SPU、SKU 规格、协作与可见性、回收站与恢复
|
||||
- **装配系统**:装配步骤、版本管理、版本恢复、装配 PDF 输出
|
||||
- **板件/孔位/五金件**:板件管理、孔位检测、五金件库、自动匹配与映射记忆
|
||||
- **材质与主题**:材质库、材质分类、主题配置与激活
|
||||
- **包装方案**:包装方案与箱体明细管理
|
||||
- **分享与协作**:分享链接、访问统计
|
||||
- **审计与报表**:操作日志、审计报表、导出
|
||||
- **文件上传**:GLB/GLTF 上传、DWG 转换(需转换服务)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 当前迭代重点
|
||||
|
||||
- **SKU 级结构数据**:板件 / 孔位 / 五金件 / 装配步骤已逐步收口到 SKU 维度,减少产品级串数据
|
||||
- **CAD 导入链路**:持续以 CAD 导出结构数据作为高优先级数据来源,完善导入、绑定和回填
|
||||
- **装配编辑器与回放**:重点优化板材点选、孔位高亮、爆炸视图、安装顺序生成与镜头调度
|
||||
- **分享与鉴权**:已补齐分享页 SKU 作用域、`/users` 页面认证状态复用等关键链路
|
||||
- **回收站与软删除**:已恢复删除 / 回收站主链路,继续做边界回归
|
||||
|
||||
> 当前主线不是“继续堆新页面”,而是把 `导入 -> 结构数据 -> 装配编辑 -> 3D 回放 -> 分享` 这条链路做稳定。
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 当前重点风险 / 待终验项
|
||||
|
||||
- 真实 SKU 下的板材点选、孔位高亮、折角线仍需持续人工终验
|
||||
- 装配播放链路虽然已经开始收口到共享播放模型,但背面安装、镜头调度仍需真实样例回归
|
||||
- `generate-from-order` 已完成主链路收口,仍需继续补限频、幂等和异常场景联调
|
||||
|
||||
---
|
||||
|
||||
## 🧭 生产就绪能力(Phase 10)
|
||||
|
||||
- 备份与恢复:`scripts/backup-db.*`、`scripts/backup-files.*`、`scripts/backup-all.*`、`scripts/restore.*`
|
||||
- 健康检查:`GET /api/health` 与 `/system-health` 页面
|
||||
- 配置校验:`scripts/validate-config.*`
|
||||
- 部署脚本:`scripts/deploy.*`、`scripts/remote-deploy.sh`
|
||||
- 生产配置:`docker-compose.prod.yml` / `docker-compose.standard.yml` / `docker-compose.lightweight.yml`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈(以代码为准)
|
||||
|
||||
- **框架**:Next.js 16.1.6 + React 19.2.4 + TypeScript 5
|
||||
- **3D**:Three.js 0.182.0 + React Three Fiber 9.5.0 + Drei 10.7.7
|
||||
- **动画**:GSAP 3.12.5
|
||||
- **数据库**:PostgreSQL + Prisma 7.3.0
|
||||
- **样式**:Tailwind CSS 4.1.18
|
||||
- **状态管理**:Zustand 5.0.2
|
||||
- **表单/校验**:React Hook Form 7.54.2 + Zod 4.3.6
|
||||
- **对象存储**:MinIO / S3 兼容
|
||||
|
||||
---
|
||||
|
||||
## 📂 目录结构(简要)
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router(页面与 API)
|
||||
├── components/ # 业务与 UI 组件
|
||||
├── server/ # 服务端逻辑(service/repo/validator)
|
||||
├── lib/ # 通用工具(存储、3D、设计系统)
|
||||
├── hooks/ # React Hooks
|
||||
├── stores/ # Zustand
|
||||
├── types/ # 类型定义
|
||||
|
||||
prisma/ # Prisma schema + seed
|
||||
scripts/ # 备份/部署/校验/测试工具脚本
|
||||
tests/ # Jest 测试(API/组件/安全/性能)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run test:watch
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
> 测试框架:Jest + React Testing Library,覆盖率阈值在 `jest.config.js` 中配置。
|
||||
|
||||
---
|
||||
|
||||
## 📖 文档导航
|
||||
|
||||
- 项目总览:`docs/项目总览.md`
|
||||
- 文档导航:`docs/README.md`
|
||||
- 项目长期记忆:`docs/agent-memory/README.md`
|
||||
- 部署指南:`docs/部署指南.md`
|
||||
- 运维指南:`docs/运维指南.md`
|
||||
- Phase 10 完成报告:`docs/Phase10完成报告.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 常用命令
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run start
|
||||
|
||||
# 数据库
|
||||
npm run prisma:generate
|
||||
npm run prisma:push
|
||||
npm run prisma:studio
|
||||
npm run prisma:seed
|
||||
|
||||
# 质量与测试
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**项目版本**:v1.1
|
||||
**最后更新**:2026-03-13
|
||||
**维护者**:开发团队
|
||||
|
||||
@ -1,619 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
public class AssemblySemanticAnalyzer
|
||||
{
|
||||
private const double FaceGapToleranceMm = 3.0;
|
||||
private const double RelaxedFaceGapToleranceMm = 10.0;
|
||||
private const double FaceOverlapRatioMin = 0.25;
|
||||
private const double OutsideMarginMm = 30.0;
|
||||
private const double MinPreassembleDistanceMm = 80.0;
|
||||
private const double HighConfidenceThreshold = 0.75;
|
||||
|
||||
private static readonly string[] MinFaces = { "left", "front", "bottom" };
|
||||
private static readonly string[] MaxFaces = { "right", "back", "top" };
|
||||
|
||||
public AssemblyData Analyze(
|
||||
IReadOnlyList<PanelInfo> panels,
|
||||
IReadOnlyList<HoleInfo> holes,
|
||||
IReadOnlyList<HoleGroupInfo> holeGroups,
|
||||
IReadOnlyList<HardwareType> hardwareTypes)
|
||||
{
|
||||
_ = holes;
|
||||
_ = holeGroups;
|
||||
|
||||
var result = new AssemblyData();
|
||||
if (panels == null || panels.Count == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var boxes = panels.ToDictionary(panel => panel.Id, BuildBox, StringComparer.OrdinalIgnoreCase);
|
||||
var modelBox = MergeBoxes(boxes.Values);
|
||||
var hardwareSignals = BuildHardwareSignals(hardwareTypes ?? Array.Empty<HardwareType>());
|
||||
var pairRelations = new List<PairRelation>();
|
||||
|
||||
for (var i = 0; i < panels.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < panels.Count; j++)
|
||||
{
|
||||
var relation = BuildPairRelation(
|
||||
boxes[panels[i].Id],
|
||||
boxes[panels[j].Id],
|
||||
hardwareSignals.TryGetValue(PairKey(panels[i].Id, panels[j].Id), out var signal) ? signal : null);
|
||||
if (relation != null)
|
||||
{
|
||||
pairRelations.Add(relation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.Connections = pairRelations
|
||||
.Select(relation => new AssemblyConnectionInfo
|
||||
{
|
||||
SourcePanelId = relation.PanelAId,
|
||||
TargetPanelId = relation.PanelBId,
|
||||
SelfFace = relation.FaceFromA,
|
||||
TargetFace = relation.FaceFromB,
|
||||
ConnectionType = relation.ConnectionType,
|
||||
Confidence = Math.Round(relation.Confidence, 4),
|
||||
})
|
||||
.OrderBy(item => item.SourcePanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(item => item.TargetPanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
var semantic = BuildPanelAssembly(panel, boxes, modelBox, pairRelations);
|
||||
result.PanelAssemblies.Add(semantic);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static PanelAssemblyInfo BuildPanelAssembly(
|
||||
PanelInfo panel,
|
||||
IReadOnlyDictionary<string, PanelBox> boxes,
|
||||
PanelBox modelBox,
|
||||
IReadOnlyList<PairRelation> pairRelations)
|
||||
{
|
||||
var box = boxes[panel.Id];
|
||||
var related = pairRelations
|
||||
.Select(relation =>
|
||||
{
|
||||
if (string.Equals(relation.PanelAId, panel.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PanelRelationView(
|
||||
relation.PanelBId,
|
||||
relation.FaceFromA,
|
||||
relation.ConnectionType,
|
||||
relation.Confidence,
|
||||
relation.Score,
|
||||
relation.HasHardware);
|
||||
}
|
||||
|
||||
if (string.Equals(relation.PanelBId, panel.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new PanelRelationView(
|
||||
relation.PanelAId,
|
||||
relation.FaceFromB,
|
||||
relation.ConnectionType,
|
||||
relation.Confidence,
|
||||
relation.Score,
|
||||
relation.HasHardware);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.Where(item => item != null)
|
||||
.OrderByDescending(item => item!.Score)
|
||||
.ToList();
|
||||
|
||||
var targets = related
|
||||
.Where(item => item!.Score >= 0.42)
|
||||
.Select(item => item!.TargetPanelId)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(4)
|
||||
.ToList();
|
||||
|
||||
var dependsOn = related
|
||||
.Where(item => item!.Score >= 0.55)
|
||||
.Select(item => item!.TargetPanelId)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
var primary = related.FirstOrDefault();
|
||||
var warnings = new List<string>();
|
||||
var choice = ChooseDirection(panel, box, modelBox, primary?.SelfFace, primary?.HasHardware ?? false);
|
||||
|
||||
if (choice == null)
|
||||
{
|
||||
warnings.Add("未找到明确装配方向,建议在 Web 端人工补全。");
|
||||
return new PanelAssemblyInfo
|
||||
{
|
||||
PanelId = panel.Id,
|
||||
MountMode = "unknown",
|
||||
AssemblyAxis = new[] { 0.0, 0.0, 0.0 },
|
||||
AssemblyDirection = "unknown",
|
||||
PreAssembleOffset = null,
|
||||
TravelDistance = 0,
|
||||
DependsOn = dependsOn,
|
||||
PrimarySupportPanelId = primary?.TargetPanelId,
|
||||
TargetPanelIds = targets,
|
||||
Mates = BuildMateMap(related),
|
||||
Confidence = 0.22,
|
||||
Warnings = warnings,
|
||||
AutoGenerated = true,
|
||||
};
|
||||
}
|
||||
|
||||
if (choice.Source == "relation" && !(primary?.HasHardware ?? false))
|
||||
{
|
||||
warnings.Add("当前方向主要依据板件配合面推导,建议结合孔位或五金关系复核。");
|
||||
}
|
||||
if (choice.Source == "fallback")
|
||||
{
|
||||
warnings.Add("当前方向主要依据板件类型和空间位置兜底,建议人工复核。");
|
||||
}
|
||||
if (!IsLikelyBoard(panel))
|
||||
{
|
||||
warnings.Add("当前实体尺寸更像块状件而不是标准板件,语义仅作兜底参考。");
|
||||
}
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
warnings.Add("未找到明确目标板件,预装距离按整体模型边界兜底计算。");
|
||||
}
|
||||
|
||||
var referenceBox = targets
|
||||
.Select(id => boxes.TryGetValue(id, out var targetBox) ? targetBox : null)
|
||||
.Where(item => item != null)
|
||||
.Cast<PanelBox>()
|
||||
.ToList();
|
||||
var travel = CalculateTravel(box, referenceBox.Count > 0 ? MergeBoxes(referenceBox) : modelBox, choice.Direction);
|
||||
var confidence = choice.Confidence;
|
||||
if (targets.Count == 0) confidence = Math.Min(confidence, 0.58);
|
||||
if (!IsLikelyBoard(panel)) confidence -= 0.14;
|
||||
confidence = Clamp01(confidence);
|
||||
if (confidence < HighConfidenceThreshold)
|
||||
{
|
||||
warnings.Add("当前语义置信度偏低,建议由产品人员在 Web 端确认后再用于正式步骤。");
|
||||
}
|
||||
|
||||
return new PanelAssemblyInfo
|
||||
{
|
||||
PanelId = panel.Id,
|
||||
MountMode = choice.MountMode,
|
||||
AssemblyAxis = AxisVector(choice.Direction),
|
||||
AssemblyDirection = choice.Direction,
|
||||
PreAssembleOffset = OffsetVector(choice.Direction, travel),
|
||||
TravelDistance = Math.Round(travel, 2),
|
||||
DependsOn = dependsOn,
|
||||
PrimarySupportPanelId = primary?.TargetPanelId,
|
||||
TargetPanelIds = targets,
|
||||
Mates = BuildMateMap(related),
|
||||
Confidence = Math.Round(confidence, 4),
|
||||
Warnings = warnings.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
AutoGenerated = true,
|
||||
};
|
||||
}
|
||||
|
||||
private static DirectionChoice? ChooseDirection(
|
||||
PanelInfo panel,
|
||||
PanelBox box,
|
||||
PanelBox modelBox,
|
||||
string? primaryFace,
|
||||
bool hasHardware)
|
||||
{
|
||||
var normalizedType = (panel.PanelType ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalizedType == "top_panel") return new DirectionChoice("top", "drop_in", 0.88, "fallback");
|
||||
if (normalizedType == "bottom_panel") return new DirectionChoice("bottom", "drop_in", 0.84, "fallback");
|
||||
if (normalizedType == "shelf") return new DirectionChoice("top", "drop_in", 0.78, "fallback");
|
||||
if (normalizedType == "back_panel")
|
||||
{
|
||||
return new DirectionChoice(box.Center[1] >= modelBox.Center[1] ? "back" : "front", "slide_in", 0.76, "fallback");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(primaryFace))
|
||||
{
|
||||
var direction = OppositeFace(primaryFace!);
|
||||
if (direction != "unknown")
|
||||
{
|
||||
return new DirectionChoice(
|
||||
direction,
|
||||
direction == "top" || direction == "bottom" ? "drop_in" : "slide_in",
|
||||
hasHardware ? 0.9 : 0.68,
|
||||
"relation");
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedType == "side_panel" || normalizedType == "vertical_divider")
|
||||
{
|
||||
return new DirectionChoice(box.Center[0] >= modelBox.Center[0] ? "right" : "left", "slide_in", 0.64, "fallback");
|
||||
}
|
||||
if (normalizedType == "horizontal_divider")
|
||||
{
|
||||
return new DirectionChoice(box.Center[1] >= modelBox.Center[1] ? "back" : "front", "slide_in", 0.62, "fallback");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PairRelation? BuildPairRelation(PanelBox boxA, PanelBox boxB, HardwareSignal? hardwareSignal)
|
||||
{
|
||||
var gapTolerance = hardwareSignal == null ? FaceGapToleranceMm : RelaxedFaceGapToleranceMm;
|
||||
FaceCandidate? best = null;
|
||||
|
||||
for (var axis = 0; axis < 3; axis++)
|
||||
{
|
||||
var axisA = (axis + 1) % 3;
|
||||
var axisB = (axis + 2) % 3;
|
||||
var overlapA = Overlap(boxA.Min[axisA], boxA.Max[axisA], boxB.Min[axisA], boxB.Max[axisA]);
|
||||
var overlapB = Overlap(boxA.Min[axisB], boxA.Max[axisB], boxB.Min[axisB], boxB.Max[axisB]);
|
||||
var ratioA = overlapA / Math.Max(1.0, Math.Min(boxA.Size[axisA], boxB.Size[axisA]));
|
||||
var ratioB = overlapB / Math.Max(1.0, Math.Min(boxA.Size[axisB], boxB.Size[axisB]));
|
||||
if (ratioA < FaceOverlapRatioMin || ratioB < FaceOverlapRatioMin) continue;
|
||||
|
||||
var aBeforeB = boxA.Center[axis] <= boxB.Center[axis];
|
||||
var gap = aBeforeB
|
||||
? Math.Abs(boxA.Max[axis] - boxB.Min[axis])
|
||||
: Math.Abs(boxB.Max[axis] - boxA.Min[axis]);
|
||||
if (gap > gapTolerance) continue;
|
||||
|
||||
var faceFromA = aBeforeB ? MaxFaces[axis] : MinFaces[axis];
|
||||
var faceFromB = OppositeFace(faceFromA);
|
||||
var score = (1.0 - gap / Math.Max(gapTolerance, 0.001)) * 0.45 + ((ratioA + ratioB) / 2.0) * 0.55;
|
||||
var candidate = new FaceCandidate(faceFromA, faceFromB, score);
|
||||
if (best == null || candidate.Score > best.Value.Score)
|
||||
{
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (best != null)
|
||||
{
|
||||
var confidence = 0.54 + best.Value.Score * 0.22 + (hardwareSignal?.Confidence ?? 0) * 0.12;
|
||||
return new PairRelation(
|
||||
boxA.Panel.Id,
|
||||
boxB.Panel.Id,
|
||||
best.Value.FaceFromA,
|
||||
best.Value.FaceFromB,
|
||||
hardwareSignal?.ConnectionType ?? "adjacent_face",
|
||||
Clamp01(confidence),
|
||||
best.Value.Score + (hardwareSignal?.Confidence ?? 0) * 0.2,
|
||||
hardwareSignal != null);
|
||||
}
|
||||
|
||||
if (hardwareSignal == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var delta = new[]
|
||||
{
|
||||
boxB.Center[0] - boxA.Center[0],
|
||||
boxB.Center[1] - boxA.Center[1],
|
||||
boxB.Center[2] - boxA.Center[2],
|
||||
};
|
||||
var dominantAxis = 0;
|
||||
if (Math.Abs(delta[1]) > Math.Abs(delta[dominantAxis])) dominantAxis = 1;
|
||||
if (Math.Abs(delta[2]) > Math.Abs(delta[dominantAxis])) dominantAxis = 2;
|
||||
|
||||
var derivedFace = delta[dominantAxis] >= 0 ? MaxFaces[dominantAxis] : MinFaces[dominantAxis];
|
||||
return new PairRelation(
|
||||
boxA.Panel.Id,
|
||||
boxB.Panel.Id,
|
||||
derivedFace,
|
||||
OppositeFace(derivedFace),
|
||||
hardwareSignal.ConnectionType,
|
||||
Clamp01(0.48 + hardwareSignal.Confidence * 0.24),
|
||||
0.5 + hardwareSignal.Confidence * 0.3,
|
||||
true);
|
||||
}
|
||||
|
||||
private static Dictionary<string, HardwareSignal> BuildHardwareSignals(IReadOnlyList<HardwareType> hardwareTypes)
|
||||
{
|
||||
var signals = new Dictionary<string, HardwareSignal>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var hardwareType in hardwareTypes)
|
||||
{
|
||||
foreach (var instance in hardwareType.Instances ?? new List<HardwareInstance>())
|
||||
{
|
||||
var primary = instance.PrimaryPanelId?.Trim();
|
||||
var secondary = instance.SecondaryPanelId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(primary) ||
|
||||
string.IsNullOrWhiteSpace(secondary) ||
|
||||
string.Equals(primary, secondary, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = PairKey(primary!, secondary!);
|
||||
if (!signals.TryGetValue(key, out var signal))
|
||||
{
|
||||
signal = new HardwareSignal();
|
||||
signals[key] = signal;
|
||||
}
|
||||
|
||||
signal.Count++;
|
||||
signal.ConfidenceSum += Clamp01(instance.BindingConfidence ?? 0.65);
|
||||
if (!string.IsNullOrWhiteSpace(instance.BoundHoleGroupId)) signal.ConnectionType = "hole_group_hardware";
|
||||
else if ((instance.BoundHoleIds?.Length ?? 0) > 0) signal.ConnectionType = "hole_hardware";
|
||||
else
|
||||
{
|
||||
var bindingMode = instance.BindingMode;
|
||||
if (bindingMode != null &&
|
||||
bindingMode.Length > 0 &&
|
||||
bindingMode.StartsWith("panel_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
signal.ConnectionType = "panel_hardware";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
private static PanelAssemblyMateMap BuildMateMap(IEnumerable<PanelRelationView?> related)
|
||||
{
|
||||
var map = new PanelAssemblyMateMap();
|
||||
foreach (var item in related.Where(static entry => entry is { Score: >= 0.42 }).Take(6))
|
||||
{
|
||||
AddMate(map, item!.SelfFace, item.TargetPanelId);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void AddMate(PanelAssemblyMateMap map, string face, string targetPanelId)
|
||||
{
|
||||
List<string>? bucket = face switch
|
||||
{
|
||||
"left" => map.Left ??= new List<string>(),
|
||||
"right" => map.Right ??= new List<string>(),
|
||||
"front" => map.Front ??= new List<string>(),
|
||||
"back" => map.Back ??= new List<string>(),
|
||||
"top" => map.Top ??= new List<string>(),
|
||||
"bottom" => map.Bottom ??= new List<string>(),
|
||||
_ => null,
|
||||
};
|
||||
if (bucket == null) return;
|
||||
if (!bucket.Contains(targetPanelId, StringComparer.OrdinalIgnoreCase)) bucket.Add(targetPanelId);
|
||||
}
|
||||
|
||||
private static double CalculateTravel(PanelBox panelBox, PanelBox referenceBox, string direction)
|
||||
{
|
||||
double travel = direction switch
|
||||
{
|
||||
"left" => panelBox.Max[0] - referenceBox.Min[0] + OutsideMarginMm,
|
||||
"right" => referenceBox.Max[0] - panelBox.Min[0] + OutsideMarginMm,
|
||||
"front" => panelBox.Max[1] - referenceBox.Min[1] + OutsideMarginMm,
|
||||
"back" => referenceBox.Max[1] - panelBox.Min[1] + OutsideMarginMm,
|
||||
"bottom" => panelBox.Max[2] - referenceBox.Min[2] + OutsideMarginMm,
|
||||
"top" => referenceBox.Max[2] - panelBox.Min[2] + OutsideMarginMm,
|
||||
_ => 0,
|
||||
};
|
||||
return Math.Round(Math.Max(MinPreassembleDistanceMm, travel), 2);
|
||||
}
|
||||
|
||||
private static double[] AxisVector(string direction)
|
||||
{
|
||||
return direction switch
|
||||
{
|
||||
"left" => new[] { -1.0, 0.0, 0.0 },
|
||||
"right" => new[] { 1.0, 0.0, 0.0 },
|
||||
"front" => new[] { 0.0, -1.0, 0.0 },
|
||||
"back" => new[] { 0.0, 1.0, 0.0 },
|
||||
"bottom" => new[] { 0.0, 0.0, -1.0 },
|
||||
"top" => new[] { 0.0, 0.0, 1.0 },
|
||||
_ => new[] { 0.0, 0.0, 0.0 },
|
||||
};
|
||||
}
|
||||
|
||||
private static double[]? OffsetVector(string direction, double travel)
|
||||
{
|
||||
var axis = AxisVector(direction);
|
||||
if (axis[0] == 0 && axis[1] == 0 && axis[2] == 0) return null;
|
||||
return new[]
|
||||
{
|
||||
Math.Round(axis[0] * travel, 2),
|
||||
Math.Round(axis[1] * travel, 2),
|
||||
Math.Round(axis[2] * travel, 2),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsLikelyBoard(PanelInfo panel)
|
||||
{
|
||||
if (panel.Size == null || panel.Size.Length < 3) return false;
|
||||
var thickness = Math.Max(panel.Size[2], 1.0);
|
||||
return thickness <= 50 && panel.Size[0] / thickness >= 2.5 && panel.Size[1] / thickness >= 1.5;
|
||||
}
|
||||
|
||||
private static PanelBox BuildBox(PanelInfo panel)
|
||||
{
|
||||
if (panel.BoundingBox != null &&
|
||||
panel.BoundingBox.Min?.Length == 3 &&
|
||||
panel.BoundingBox.Max?.Length == 3)
|
||||
{
|
||||
return new PanelBox(panel, panel.BoundingBox.Min, panel.BoundingBox.Max);
|
||||
}
|
||||
|
||||
var center = panel.Position?.Length == 3 ? panel.Position : new[] { 0.0, 0.0, 0.0 };
|
||||
var size = panel.Size?.Length == 3 ? panel.Size : new[] { 0.0, 0.0, 0.0 };
|
||||
var normal = panel.Normal?.Length == 3 ? panel.Normal : new[] { 0.0, 0.0, 1.0 };
|
||||
var thicknessAxis = Math.Abs(normal[0]) >= Math.Abs(normal[1]) && Math.Abs(normal[0]) >= Math.Abs(normal[2]) ? 0 :
|
||||
Math.Abs(normal[1]) >= Math.Abs(normal[2]) ? 1 : 2;
|
||||
var axes = Enumerable.Range(0, 3).Where(axis => axis != thicknessAxis).ToArray();
|
||||
var axisSizes = new double[3];
|
||||
axisSizes[thicknessAxis] = size[2];
|
||||
axisSizes[axes[0]] = size[0];
|
||||
axisSizes[axes[1]] = size[1];
|
||||
|
||||
return new PanelBox(
|
||||
panel,
|
||||
new[]
|
||||
{
|
||||
center[0] - axisSizes[0] / 2.0,
|
||||
center[1] - axisSizes[1] / 2.0,
|
||||
center[2] - axisSizes[2] / 2.0,
|
||||
},
|
||||
new[]
|
||||
{
|
||||
center[0] + axisSizes[0] / 2.0,
|
||||
center[1] + axisSizes[1] / 2.0,
|
||||
center[2] + axisSizes[2] / 2.0,
|
||||
});
|
||||
}
|
||||
|
||||
private static PanelBox MergeBoxes(IEnumerable<PanelBox> boxes)
|
||||
{
|
||||
var min = new[] { double.MaxValue, double.MaxValue, double.MaxValue };
|
||||
var max = new[] { double.MinValue, double.MinValue, double.MinValue };
|
||||
foreach (var box in boxes)
|
||||
{
|
||||
for (var axis = 0; axis < 3; axis++)
|
||||
{
|
||||
min[axis] = Math.Min(min[axis], box.Min[axis]);
|
||||
max[axis] = Math.Max(max[axis], box.Max[axis]);
|
||||
}
|
||||
}
|
||||
|
||||
return new PanelBox(new PanelInfo { Id = "__merged__" }, min, max);
|
||||
}
|
||||
|
||||
private static string PairKey(string panelIdA, string panelIdB)
|
||||
{
|
||||
return string.Compare(panelIdA, panelIdB, StringComparison.OrdinalIgnoreCase) <= 0
|
||||
? $"{panelIdA}::{panelIdB}"
|
||||
: $"{panelIdB}::{panelIdA}";
|
||||
}
|
||||
|
||||
private static string OppositeFace(string face)
|
||||
{
|
||||
return face switch
|
||||
{
|
||||
"left" => "right",
|
||||
"right" => "left",
|
||||
"front" => "back",
|
||||
"back" => "front",
|
||||
"top" => "bottom",
|
||||
"bottom" => "top",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private static double Overlap(double minA, double maxA, double minB, double maxB)
|
||||
{
|
||||
return Math.Max(0.0, Math.Min(maxA, maxB) - Math.Max(minA, minB));
|
||||
}
|
||||
|
||||
private static double Clamp01(double value)
|
||||
{
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
private readonly struct FaceCandidate
|
||||
{
|
||||
public FaceCandidate(string faceFromA, string faceFromB, double score)
|
||||
{
|
||||
FaceFromA = faceFromA;
|
||||
FaceFromB = faceFromB;
|
||||
Score = score;
|
||||
}
|
||||
|
||||
public string FaceFromA { get; }
|
||||
public string FaceFromB { get; }
|
||||
public double Score { get; }
|
||||
}
|
||||
|
||||
private sealed class PairRelation
|
||||
{
|
||||
public PairRelation(string panelAId, string panelBId, string faceFromA, string faceFromB, string connectionType, double confidence, double score, bool hasHardware)
|
||||
{
|
||||
PanelAId = panelAId;
|
||||
PanelBId = panelBId;
|
||||
FaceFromA = faceFromA;
|
||||
FaceFromB = faceFromB;
|
||||
ConnectionType = connectionType;
|
||||
Confidence = confidence;
|
||||
Score = score;
|
||||
HasHardware = hasHardware;
|
||||
}
|
||||
|
||||
public string PanelAId { get; }
|
||||
public string PanelBId { get; }
|
||||
public string FaceFromA { get; }
|
||||
public string FaceFromB { get; }
|
||||
public string ConnectionType { get; }
|
||||
public double Confidence { get; }
|
||||
public double Score { get; }
|
||||
public bool HasHardware { get; }
|
||||
}
|
||||
|
||||
private sealed class PanelRelationView
|
||||
{
|
||||
public PanelRelationView(string targetPanelId, string selfFace, string connectionType, double confidence, double score, bool hasHardware)
|
||||
{
|
||||
TargetPanelId = targetPanelId;
|
||||
SelfFace = selfFace;
|
||||
ConnectionType = connectionType;
|
||||
Confidence = confidence;
|
||||
Score = score;
|
||||
HasHardware = hasHardware;
|
||||
}
|
||||
|
||||
public string TargetPanelId { get; }
|
||||
public string SelfFace { get; }
|
||||
public string ConnectionType { get; }
|
||||
public double Confidence { get; }
|
||||
public double Score { get; }
|
||||
public bool HasHardware { get; }
|
||||
}
|
||||
|
||||
private sealed class PanelBox
|
||||
{
|
||||
public PanelBox(PanelInfo panel, IReadOnlyList<double> min, IReadOnlyList<double> max)
|
||||
{
|
||||
Panel = panel;
|
||||
Min = min.ToArray();
|
||||
Max = max.ToArray();
|
||||
Size = new[] { Math.Abs(Max[0] - Min[0]), Math.Abs(Max[1] - Min[1]), Math.Abs(Max[2] - Min[2]) };
|
||||
Center = new[] { (Min[0] + Max[0]) / 2.0, (Min[1] + Max[1]) / 2.0, (Min[2] + Max[2]) / 2.0 };
|
||||
}
|
||||
|
||||
public PanelInfo Panel { get; }
|
||||
public double[] Min { get; }
|
||||
public double[] Max { get; }
|
||||
public double[] Size { get; }
|
||||
public double[] Center { get; }
|
||||
}
|
||||
|
||||
private sealed class DirectionChoice
|
||||
{
|
||||
public DirectionChoice(string direction, string mountMode, double confidence, string source)
|
||||
{
|
||||
Direction = direction;
|
||||
MountMode = mountMode;
|
||||
Confidence = confidence;
|
||||
Source = source;
|
||||
}
|
||||
|
||||
public string Direction { get; }
|
||||
public string MountMode { get; }
|
||||
public double Confidence { get; }
|
||||
public string Source { get; }
|
||||
}
|
||||
|
||||
private sealed class HardwareSignal
|
||||
{
|
||||
public int Count { get; set; }
|
||||
public double ConfidenceSum { get; set; }
|
||||
public string ConnectionType { get; set; } = "panel_hardware";
|
||||
public double Confidence => Count <= 0 ? 0 : ConfidenceSum / Count;
|
||||
}
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
using Autodesk.AutoCAD.Geometry;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// Brep 面的 Surface 有时会被包装成 ExternalBoundedSurface/OffsetSurface 等“修剪/偏移”表面。
|
||||
/// 这会导致直接用 `surface is Cylinder/Plane` 判断失效,从而漏检孔位/倒角等特征。
|
||||
///
|
||||
/// 这里提供一个统一的“剥壳”工具,把 Surface 还原为最底层的几何表面类型。
|
||||
/// </summary>
|
||||
internal static class BrepSurfaceUtils
|
||||
{
|
||||
// 防御:最多剥 8 层,避免异常几何导致递归/循环
|
||||
private const int MaxUnwrapDepth = 8;
|
||||
|
||||
public static Surface Unwrap(Surface surface)
|
||||
{
|
||||
var current = surface;
|
||||
|
||||
for (int i = 0; i < MaxUnwrapDepth; i++)
|
||||
{
|
||||
if (current == null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// 最常见:Brep Face 的表面是“修剪面”,真正的几何在 BaseSurface
|
||||
if (current is ExternalBoundedSurface ebs && ebs.BaseSurface != null)
|
||||
{
|
||||
current = ebs.BaseSurface;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 偏移面:原始几何在 OriginalSurface
|
||||
if (current is OffsetSurface os && os.OriginalSurface != null)
|
||||
{
|
||||
current = os.OriginalSurface;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 外部面:如果是 NativeSurface,则真实几何在 nativeSurface
|
||||
if (current is ExternalSurface es && es.IsNativeSurface && es.nativeSurface != null)
|
||||
{
|
||||
current = es.nativeSurface;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return current ?? surface;
|
||||
}
|
||||
}
|
||||
@ -1,302 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Autodesk.AutoCAD.DatabaseServices;
|
||||
using Autodesk.AutoCAD.Geometry;
|
||||
using Autodesk.AutoCAD.BoundaryRepresentation;
|
||||
using BrepFace = Autodesk.AutoCAD.BoundaryRepresentation.Face;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 倒角检测器 - 从板材实体中提取倒角/圆角信息
|
||||
/// </summary>
|
||||
public class ChamferDetector
|
||||
{
|
||||
private readonly Database _db;
|
||||
private Point3d _originOffset = Point3d.Origin;
|
||||
|
||||
// 检测参数
|
||||
private const double MIN_CHAMFER_SIZE = 0.5; // 最小倒角尺寸 0.5mm
|
||||
private const double MAX_CHAMFER_SIZE = 50; // 最大倒角尺寸 50mm
|
||||
private const double MIN_FILLET_RADIUS = 1; // 最小圆角半径 1mm
|
||||
private const double MAX_FILLET_RADIUS = 100; // 最大圆角半径 100mm
|
||||
|
||||
public ChamferDetector(Database db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从板材实体中检测倒角和圆角
|
||||
/// </summary>
|
||||
public List<EdgeFeatureInfo> DetectEdgeFeatures(ObjectId solidId, string panelId, Point3d? originOffset = null)
|
||||
{
|
||||
_originOffset = originOffset ?? Point3d.Origin;
|
||||
var features = new List<EdgeFeatureInfo>();
|
||||
|
||||
using var tr = _db.TransactionManager.StartTransaction();
|
||||
|
||||
var solid = tr.GetObject(solidId, OpenMode.ForRead) as Solid3d;
|
||||
if (solid == null)
|
||||
{
|
||||
tr.Commit();
|
||||
return features;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var brep = new Brep(solid);
|
||||
int featureIndex = 0;
|
||||
|
||||
// 遍历所有面,检测圆柱面(圆角)和平面相交的特殊边
|
||||
foreach (var face in brep.Faces)
|
||||
{
|
||||
try
|
||||
{
|
||||
// face.Surface 常常是修剪/偏移表面,先剥离到基础几何类型
|
||||
var surface = BrepSurfaceUtils.Unwrap(face.Surface);
|
||||
|
||||
// 检测圆角(圆柱面)
|
||||
if (surface is Cylinder cylinder)
|
||||
{
|
||||
var radius = cylinder.Radius;
|
||||
|
||||
// 过滤:圆角的半径通常较小,而孔的半径通常大于5mm
|
||||
if (radius >= MIN_FILLET_RADIUS && radius <= MAX_FILLET_RADIUS)
|
||||
{
|
||||
// 判断是圆角还是孔
|
||||
if (IsFilletFace(face, cylinder, solid))
|
||||
{
|
||||
var feature = ExtractFilletInfo(cylinder, face, panelId, featureIndex++);
|
||||
if (feature != null)
|
||||
{
|
||||
features.Add(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 检测倒角(倾斜平面)
|
||||
else if (surface is Plane plane)
|
||||
{
|
||||
if (IsChamferFace(face, plane, solid))
|
||||
{
|
||||
var feature = ExtractChamferInfo(plane, face, panelId, featureIndex++);
|
||||
if (feature != null)
|
||||
{
|
||||
features.Add(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[ChamferDetector] Error: {ex.Message}");
|
||||
}
|
||||
|
||||
tr.Commit();
|
||||
return features;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断圆柱面是否为圆角(而非孔的侧面)
|
||||
/// </summary>
|
||||
private bool IsFilletFace(BrepFace face, Cylinder cylinder, Solid3d solid)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取圆柱半径
|
||||
var radius = cylinder.Radius;
|
||||
|
||||
// 获取实体尺寸
|
||||
var solidExt = solid.GeometricExtents;
|
||||
var solidSize = solidExt.MaxPoint - solidExt.MinPoint;
|
||||
var maxSolidDim = Math.Max(solidSize.X, Math.Max(solidSize.Y, solidSize.Z));
|
||||
|
||||
// 圆角特征:半径较小(相对于实体尺寸)
|
||||
var radiusRatio = radius / maxSolidDim;
|
||||
|
||||
// 如果半径小于实体最大尺寸的10%,很可能是圆角
|
||||
if (radiusRatio < 0.1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断平面是否为倒角面
|
||||
/// </summary>
|
||||
private bool IsChamferFace(BrepFace face, Plane plane, Solid3d solid)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取平面法向量
|
||||
var normal = plane.Normal.GetNormal();
|
||||
|
||||
// 倒角面的法向量不会与主轴完全平行
|
||||
var absNormal = new Vector3d(Math.Abs(normal.X), Math.Abs(normal.Y), Math.Abs(normal.Z));
|
||||
|
||||
// 如果法向量与任意主轴的夹角在25°-65°之间,可能是倒角
|
||||
var maxComponent = Math.Max(absNormal.X, Math.Max(absNormal.Y, absNormal.Z));
|
||||
var minComponent = Math.Min(absNormal.X, Math.Min(absNormal.Y, absNormal.Z));
|
||||
|
||||
// 倒角面的法向量不会太接近主轴(>0.9)也不会太远离(<0.4)
|
||||
if (maxComponent > 0.4 && maxComponent < 0.9 && minComponent > 0.1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提取圆角信息
|
||||
/// </summary>
|
||||
private EdgeFeatureInfo? ExtractFilletInfo(Cylinder cylinder, BrepFace face, string panelId, int index)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用圆柱原点作为圆角位置
|
||||
var origin = cylinder.Origin;
|
||||
var center = new Point3d(
|
||||
origin.X + _originOffset.X,
|
||||
origin.Y + _originOffset.Y,
|
||||
origin.Z + _originOffset.Z
|
||||
);
|
||||
|
||||
var axis = cylinder.AxisOfSymmetry.GetNormal();
|
||||
|
||||
return new EdgeFeatureInfo
|
||||
{
|
||||
Id = $"{panelId}_fillet_{index:D3}",
|
||||
Type = "fillet",
|
||||
Position = new double[]
|
||||
{
|
||||
Math.Round(center.X, 2),
|
||||
Math.Round(center.Y, 2),
|
||||
Math.Round(center.Z, 2)
|
||||
},
|
||||
Direction = new double[]
|
||||
{
|
||||
Math.Round(axis.X, 4),
|
||||
Math.Round(axis.Y, 4),
|
||||
Math.Round(axis.Z, 4)
|
||||
},
|
||||
Radius = Math.Round(cylinder.Radius, 2),
|
||||
Length = 0, // 长度无法准确获取
|
||||
PanelId = panelId
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提取倒角信息
|
||||
/// </summary>
|
||||
private EdgeFeatureInfo? ExtractChamferInfo(Plane plane, BrepFace face, string panelId, int index)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用平面原点作为倒角位置
|
||||
var origin = plane.PointOnPlane;
|
||||
var center = new Point3d(
|
||||
origin.X + _originOffset.X,
|
||||
origin.Y + _originOffset.Y,
|
||||
origin.Z + _originOffset.Z
|
||||
);
|
||||
|
||||
var normal = plane.Normal.GetNormal();
|
||||
|
||||
return new EdgeFeatureInfo
|
||||
{
|
||||
Id = $"{panelId}_chamfer_{index:D3}",
|
||||
Type = "chamfer",
|
||||
Position = new double[]
|
||||
{
|
||||
Math.Round(center.X, 2),
|
||||
Math.Round(center.Y, 2),
|
||||
Math.Round(center.Z, 2)
|
||||
},
|
||||
Direction = new double[]
|
||||
{
|
||||
Math.Round(normal.X, 4),
|
||||
Math.Round(normal.Y, 4),
|
||||
Math.Round(normal.Z, 4)
|
||||
},
|
||||
Size = 0, // 倒角尺寸(无法准确获取)
|
||||
Length = 0,
|
||||
PanelId = panelId
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 边缘特征信息(倒角/圆角)
|
||||
/// </summary>
|
||||
public class EdgeFeatureInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 特征 ID
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 特征类型: "fillet"(圆角)| "chamfer"(倒角)
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 特征中心位置 [X, Y, Z] (mm)
|
||||
/// </summary>
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 特征方向/法向量 [X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Direction { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 圆角半径 (mm),仅用于 fillet 类型
|
||||
/// </summary>
|
||||
public double? Radius { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 倒角尺寸 (mm),仅用于 chamfer 类型
|
||||
/// </summary>
|
||||
public double? Size { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 特征长度 (mm)
|
||||
/// </summary>
|
||||
public double Length { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属板材 ID
|
||||
/// </summary>
|
||||
public string PanelId { get; set; } = "";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,67 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
internal sealed class PendingExportSession
|
||||
{
|
||||
public PendingExportSession(string documentKey, IReadOnlyList<string> panelHandles)
|
||||
{
|
||||
DocumentKey = documentKey;
|
||||
PanelHandles = panelHandles;
|
||||
CreatedAtUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public string DocumentKey { get; }
|
||||
public DateTime CreatedAtUtc { get; }
|
||||
public IReadOnlyList<string> PanelHandles { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出向导的“暂停/恢复”会话管理。
|
||||
/// 仅保存在内存中(不落盘),用于允许用户在两步之间调整图层显示等操作。
|
||||
/// </summary>
|
||||
internal static class ExportSessionManager
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly Dictionary<string, PendingExportSession> Sessions =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static PendingExportSession? Get(string documentKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(documentKey)) return null;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
Sessions.TryGetValue(documentKey, out var session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
public static PendingExportSession SetPanels(string documentKey, IReadOnlyList<string> panelHandles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(documentKey))
|
||||
throw new ArgumentException("documentKey is required", nameof(documentKey));
|
||||
|
||||
if (panelHandles == null)
|
||||
throw new ArgumentNullException(nameof(panelHandles));
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
var session = new PendingExportSession(documentKey, panelHandles);
|
||||
Sessions[documentKey] = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear(string documentKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(documentKey)) return;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
Sessions.Remove(documentKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,250 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 紧固件推断器(v2.3)
|
||||
///
|
||||
/// 目标:
|
||||
/// - 不增加设计师操作:导出时自动给出“更像哪种螺丝直径”的候选(Top1/Top2);
|
||||
/// - 避免“4/5/6 框死”的硬匹配:采用区间+评分+先验权重;
|
||||
/// - 先验来自五金标准库(此处先用表格统计的出现频次做近似权重)。
|
||||
///
|
||||
/// 注意:
|
||||
/// - 孔的几何通常无法唯一确定螺丝长度,因此这里输出的是“直径候选 + 常见规格示例”;
|
||||
/// - 后续如果导出能拿到更多特征(例如沉孔/沉头、预埋件类型、板厚/孔深约束),再逐步提高确定性。
|
||||
/// </summary>
|
||||
public static class FastenerGuesser
|
||||
{
|
||||
/// <summary>
|
||||
/// 内置“常见螺丝规格库(示例)”
|
||||
/// 来自:截图/新配件明细表-2023815.xlsx 的条目统计(出现次数,不代表实际用量)
|
||||
/// 说明:
|
||||
/// - 这里只保留常见直径段(6/4/5/8/3.5)和高频长度,避免导出 JSON 过大;
|
||||
/// - 如果你们后续要做成可配置,可把这里迁移成随插件发布的 JSON 配置。
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<double, (double PriorWeight, string[] CommonSpecs)> ScrewCatalog =
|
||||
new Dictionary<double, (double, string[])>
|
||||
{
|
||||
// 先验权重近似取:该直径在表格出现次数 / 总出现次数(只统计能解析规格的螺丝项)
|
||||
// 6mm:58, 4mm:30, 5mm:21, 8mm:15, 3.5mm:14 => 总计 138(其余直径更分散,这里先忽略)
|
||||
[6.0] = (58.0 / 138.0, new[] { "6*15", "6*20", "6*35", "6*12", "6*23", "6*25", "6*28", "6*45", "6*60", "6*65" }),
|
||||
[4.0] = (30.0 / 138.0, new[] { "4*25", "4*10", "4*16", "4*20", "4*30", "4*14" }),
|
||||
[5.0] = (21.0 / 138.0, new[] { "5*40", "5*45", "5*50", "5*60", "5*30", "5*70" }),
|
||||
[8.0] = (15.0 / 138.0, new[] { "8*40", "8*10", "8*15", "8*25", "8*30" }),
|
||||
[3.5] = (14.0 / 138.0, new[] { "3.5*12", "3.5*8", "3.5*20" }),
|
||||
};
|
||||
|
||||
// 经验参数(可后续抽到配置里)
|
||||
// v2.3.2:通孔“间隙孔”的区间不要设太宽。
|
||||
// 之前 0.2~1.2 会导致 4.8mm 孔被错误解释为 M4 通孔(实际上更常见是 M6 的导孔/预钻孔,或 5mm 层板托孔体系)。
|
||||
// 这里把上限收紧,避免“过宽区间”吞掉大量 4.8mm 的孔。
|
||||
private const double ThroughClearanceMin = 0.1; // 通孔间隙下限(mm)
|
||||
private const double ThroughClearanceMax = 0.6; // 通孔间隙上限(mm)
|
||||
private const double PilotRatioMin = 0.65; // 盲孔导孔直径 = 螺丝直径 * ratio
|
||||
private const double PilotRatioMax = 0.85;
|
||||
|
||||
/// <summary>
|
||||
/// 给单个孔位生成螺丝候选(Top N)
|
||||
/// </summary>
|
||||
public static List<FastenerSuggestion> SuggestForHole(HoleInfo hole, int topN = 2)
|
||||
{
|
||||
// v2.2.3:让 suggestedFasteners 覆盖更多“非螺丝孔”(例如木榫/过线孔),
|
||||
// 这样导出数据更完整,也更贴近用户“我数了多少孔就应该有多少条建议/识别结果”的直觉。
|
||||
// 这里仍然保持“螺丝候选”只在螺丝孔上输出多候选;对木榫/过线孔输出确定性建议(confidence=1)。
|
||||
var usage = (hole.InferredUsage ?? "").Trim();
|
||||
if (usage.StartsWith("dowel_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var dia = ParseDowelDiameter(usage);
|
||||
if (dia > 0)
|
||||
{
|
||||
return new List<FastenerSuggestion>
|
||||
{
|
||||
new FastenerSuggestion
|
||||
{
|
||||
Category = "dowel",
|
||||
NominalDiameter = dia,
|
||||
Confidence = 1.0,
|
||||
Reason = $"用途推断为 {usage},按孔径 {hole.Diameter:F1}mm 输出木榫建议"
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (usage.Equals("cable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new List<FastenerSuggestion>
|
||||
{
|
||||
new FastenerSuggestion
|
||||
{
|
||||
Category = "cable_hole",
|
||||
NominalDiameter = Math.Round(hole.Diameter, 2),
|
||||
Confidence = 1.0,
|
||||
Reason = $"用途推断为过线孔(孔径 {hole.Diameter:F1}mm)"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (usage.Equals("hinge", StringComparison.OrdinalIgnoreCase) || usage.StartsWith("connector", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new List<FastenerSuggestion>
|
||||
{
|
||||
new FastenerSuggestion
|
||||
{
|
||||
Category = usage,
|
||||
NominalDiameter = Math.Round(hole.Diameter, 2),
|
||||
Confidence = 1.0,
|
||||
Reason = $"用途推断为 {usage}(孔径 {hole.Diameter:F1}mm),不输出螺丝候选"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 只在“看起来像螺丝相关”的孔上给建议:
|
||||
// - 连接件(偏心轮孔 15mm、木榫 8/10/6)等会污染候选
|
||||
// - 这里使用直径阈值做第一层过滤(后续可结合 InferredUsage 更精细)
|
||||
if (hole.Diameter >= 7.5 && hole.Diameter <= 16.5)
|
||||
{
|
||||
// 8/10/12/15 一带,往往是木榫/偏心件/三合一体系,先不输出螺丝候选
|
||||
return new List<FastenerSuggestion>();
|
||||
}
|
||||
|
||||
if (hole.Diameter < 2.0 || hole.Diameter > 10.0)
|
||||
{
|
||||
// 太小/太大都不像常规螺丝孔(大孔可能是过线孔/脚孔等)
|
||||
return new List<FastenerSuggestion>();
|
||||
}
|
||||
|
||||
var candidates = new List<(double Dia, double Score, string Reason)>();
|
||||
foreach (var kvp in ScrewCatalog)
|
||||
{
|
||||
var dia = kvp.Key;
|
||||
var prior = kvp.Value.PriorWeight;
|
||||
|
||||
// v2.3.1:不要把 hole.Type 当成“绝对真相”。
|
||||
// 实际图纸/建模中,设计师可能把导孔也画成“贯穿”,导致 through/blind 在几何上不稳定。
|
||||
// 因此这里同时计算“通孔间隙孔”和“盲孔导孔”两套评分,再根据 hole.Type 做轻量倾斜。
|
||||
var scored = ScoreCandidate(hole, dia, prior);
|
||||
|
||||
if (scored.Score <= 0.001)
|
||||
continue;
|
||||
|
||||
candidates.Add((dia, scored.Score, scored.Reason));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return new List<FastenerSuggestion>();
|
||||
|
||||
// 排序 + 置信度归一化
|
||||
var top = candidates
|
||||
.OrderByDescending(c => c.Score)
|
||||
.Take(Math.Max(1, topN))
|
||||
.ToList();
|
||||
|
||||
var sum = top.Sum(t => t.Score);
|
||||
if (sum <= 1e-9)
|
||||
sum = 1;
|
||||
|
||||
return top.Select(t =>
|
||||
{
|
||||
var catalog = ScrewCatalog[t.Dia];
|
||||
return new FastenerSuggestion
|
||||
{
|
||||
Category = "screw",
|
||||
NominalDiameter = t.Dia,
|
||||
CommonSpecs = catalog.CommonSpecs.ToList(),
|
||||
Confidence = Math.Round(t.Score / sum, 4),
|
||||
Reason = t.Reason
|
||||
};
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static double ParseDowelDiameter(string usage)
|
||||
{
|
||||
// 约定:InferHoleUsage 对木榫输出 dowel_6 / dowel_8 / dowel_10
|
||||
var parts = usage.Split('_');
|
||||
if (parts.Length != 2)
|
||||
return 0;
|
||||
|
||||
if (double.TryParse(parts[1], out var dia))
|
||||
return dia;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static (double Score, string ModeName, double Min, double Max, double Center, string Reason) ScoreCandidate(
|
||||
HoleInfo hole,
|
||||
double screwDia,
|
||||
double prior)
|
||||
{
|
||||
var isThrough = string.Equals(hole.Type, "through", StringComparison.OrdinalIgnoreCase);
|
||||
var isBlind = string.Equals(hole.Type, "blind", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// 1) 通孔间隙孔模型
|
||||
var throughMin = screwDia + ThroughClearanceMin;
|
||||
var throughMax = screwDia + ThroughClearanceMax;
|
||||
var throughCenter = (throughMin + throughMax) / 2.0;
|
||||
var throughScore = ScoreByDistance(hole.Diameter, throughMin, throughMax, throughCenter);
|
||||
// 与 hole.Type 倾斜:类型一致加权,不一致也保留一定分值(避免误分类导致“全错”)
|
||||
throughScore *= isThrough ? 1.0 : 0.6;
|
||||
|
||||
// 2) 盲孔导孔模型
|
||||
var pilotMin = screwDia * PilotRatioMin;
|
||||
var pilotMax = screwDia * PilotRatioMax;
|
||||
var pilotCenter = (pilotMin + pilotMax) / 2.0;
|
||||
var pilotScore = ScoreByDistance(hole.Diameter, pilotMin, pilotMax, pilotCenter);
|
||||
pilotScore *= isBlind ? 1.0 : 0.6;
|
||||
|
||||
// 选择更高的一种解释
|
||||
if (pilotScore >= throughScore)
|
||||
{
|
||||
var score = pilotScore * (0.5 + prior); // prior 做温和加权,避免先验过度压制几何
|
||||
var reason = $"盲孔导孔: 孔径 {hole.Diameter:F1}mm 匹配 {screwDia:0.#}mm(期望 {pilotMin:0.#}~{pilotMax:0.#},先验 {prior:P0})";
|
||||
return (score, "盲孔导孔", pilotMin, pilotMax, pilotCenter, reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
var score = throughScore * (0.5 + prior);
|
||||
var reason = $"通孔间隙孔: 孔径 {hole.Diameter:F1}mm 匹配 {screwDia:0.#}mm(期望 {throughMin:0.#}~{throughMax:0.#},先验 {prior:P0})";
|
||||
return (score, "通孔间隙孔", throughMin, throughMax, throughCenter, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据“孔径离期望区间的距离”给分
|
||||
/// - 在区间内:线性衰减(越靠近中心分越高)
|
||||
/// - 在区间外:指数衰减(离得越远越低)
|
||||
/// </summary>
|
||||
private static double ScoreByDistance(double holeDia, double min, double max, double center)
|
||||
{
|
||||
if (max <= min)
|
||||
return 0;
|
||||
|
||||
var halfWidth = (max - min) / 2.0;
|
||||
if (halfWidth <= 1e-9)
|
||||
halfWidth = 0.1;
|
||||
|
||||
if (holeDia >= min && holeDia <= max)
|
||||
{
|
||||
var d = Math.Abs(holeDia - center);
|
||||
var score = 1.0 - (d / halfWidth); // center=1, edge=0
|
||||
return Clamp(score, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// 区间外:按距离做快速衰减(单位 mm)
|
||||
var dist = holeDia < min ? (min - holeDia) : (holeDia - max);
|
||||
var decay = Math.Exp(-1.2 * dist);
|
||||
return Clamp(0.25 * decay, 0.0, 0.25); // 区间外最多给 0.25,避免“硬猜”
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// .NET Framework 4.8 没有 Math.Clamp,这里做一个简单实现。
|
||||
/// </summary>
|
||||
private static double Clamp(double value, double min, double max)
|
||||
{
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -1,236 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace FurnitureExporter.Tests
|
||||
{
|
||||
public class AssemblySemanticAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Analyze_BuildsAssemblySection_AndDoesNotEmitInstallOrderFields()
|
||||
{
|
||||
var exportData = new ExportData
|
||||
{
|
||||
Panels = new List<PanelInfo>
|
||||
{
|
||||
CreatePanel(
|
||||
"panel_001",
|
||||
"side_panel",
|
||||
new[] { 9.0, 280.0, 360.0 },
|
||||
new[] { 1.0, 0.0, 0.0 },
|
||||
new[] { 0.0, 0.0, 0.0 },
|
||||
new[] { 18.0, 560.0, 720.0 }),
|
||||
CreatePanel(
|
||||
"panel_002",
|
||||
"shelf",
|
||||
new[] { 200.0, 280.0, 359.0 },
|
||||
new[] { 0.0, 0.0, 1.0 },
|
||||
new[] { 18.0, 0.0, 350.0 },
|
||||
new[] { 382.0, 560.0, 368.0 }),
|
||||
},
|
||||
};
|
||||
|
||||
exportData.Assembly = new AssemblySemanticAnalyzer().Analyze(
|
||||
exportData.Panels,
|
||||
exportData.Holes,
|
||||
exportData.HoleGroups,
|
||||
exportData.HardwareTypes);
|
||||
|
||||
var json = JsonConvert.SerializeObject(
|
||||
exportData,
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
});
|
||||
|
||||
var root = JObject.Parse(json);
|
||||
Assert.Equal("1.0", root["assembly"]?["version"]?.Value<string>());
|
||||
Assert.NotNull(root["assembly"]?["panelAssemblies"]);
|
||||
Assert.NotNull(root["assembly"]?["connections"]);
|
||||
Assert.DoesNotContain("installOrder", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_PrefersHardwareRelation_ForConnectedPanel()
|
||||
{
|
||||
var panels = new List<PanelInfo>
|
||||
{
|
||||
CreatePanel(
|
||||
"panel_001",
|
||||
"side_panel",
|
||||
new[] { 9.0, 280.0, 360.0 },
|
||||
new[] { 1.0, 0.0, 0.0 },
|
||||
new[] { 0.0, 0.0, 0.0 },
|
||||
new[] { 18.0, 560.0, 720.0 }),
|
||||
CreatePanel(
|
||||
"panel_002",
|
||||
"shelf",
|
||||
new[] { 200.0, 280.0, 359.0 },
|
||||
new[] { 0.0, 0.0, 1.0 },
|
||||
new[] { 18.0, 0.0, 350.0 },
|
||||
new[] { 382.0, 560.0, 368.0 }),
|
||||
};
|
||||
|
||||
var holes = new List<HoleInfo>
|
||||
{
|
||||
new HoleInfo
|
||||
{
|
||||
Id = "panel_001_hole_001",
|
||||
PanelId = "panel_001",
|
||||
Position = new[] { 9.0, 260.0, 360.0 },
|
||||
Normal = new[] { 1.0, 0.0, 0.0 },
|
||||
Diameter = 15.0,
|
||||
Radius = 7.5,
|
||||
Depth = 12.0,
|
||||
Type = "blind",
|
||||
},
|
||||
new HoleInfo
|
||||
{
|
||||
Id = "panel_001_hole_002",
|
||||
PanelId = "panel_001",
|
||||
Position = new[] { 16.0, 260.0, 360.0 },
|
||||
Normal = new[] { 0.0, 1.0, 0.0 },
|
||||
Diameter = 8.0,
|
||||
Radius = 4.0,
|
||||
Depth = 10.0,
|
||||
Type = "blind",
|
||||
},
|
||||
};
|
||||
|
||||
var holeGroups = new List<HoleGroupInfo>
|
||||
{
|
||||
new HoleGroupInfo
|
||||
{
|
||||
Id = "panel_001_group_000",
|
||||
PanelId = "panel_001",
|
||||
GroupType = "eccentric_screw",
|
||||
HoleIds = new[] { "panel_001_hole_001", "panel_001_hole_002" },
|
||||
Position = new[] { 9.0, 260.0, 360.0 },
|
||||
Normal = new[] { 1.0, 0.0, 0.0 },
|
||||
Confidence = 0.96,
|
||||
Reason = "偏心件孔组",
|
||||
},
|
||||
};
|
||||
|
||||
panels[0].Holes = holes.ToList();
|
||||
panels[0].HoleGroups = holeGroups.ToList();
|
||||
|
||||
var hardwareTypes = new List<HardwareType>
|
||||
{
|
||||
new HardwareType
|
||||
{
|
||||
TypeId = "type_000",
|
||||
InferredHardwareType = "connector",
|
||||
Instances = new List<HardwareInstance>
|
||||
{
|
||||
new HardwareInstance
|
||||
{
|
||||
Position = new Position3D { X = 12.0, Y = 260.0, Z = 360.0 },
|
||||
PrimaryPanelId = "panel_001",
|
||||
SecondaryPanelId = "panel_002",
|
||||
BoundHoleGroupId = "panel_001_group_000",
|
||||
BoundHoleIds = new[] { "panel_001_hole_001", "panel_001_hole_002" },
|
||||
BindingMode = "hole_group",
|
||||
BindingConfidence = 0.97,
|
||||
BindingReason = "优先按孔组绑定",
|
||||
NearbyPanelIds = new[] { "panel_001", "panel_002" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var assembly = new AssemblySemanticAnalyzer().Analyze(panels, holes, holeGroups, hardwareTypes);
|
||||
var connection = Assert.Single(
|
||||
assembly.Connections.Where(item =>
|
||||
item.SourcePanelId == "panel_001" &&
|
||||
item.TargetPanelId == "panel_002"));
|
||||
var panelAssembly = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_001"));
|
||||
|
||||
Assert.Equal("hole_group_hardware", connection.ConnectionType);
|
||||
Assert.Equal("slide_in", panelAssembly.MountMode);
|
||||
Assert.Equal("left", panelAssembly.AssemblyDirection);
|
||||
Assert.Equal("panel_002", panelAssembly.PrimarySupportPanelId);
|
||||
Assert.Contains("panel_002", panelAssembly.TargetPanelIds);
|
||||
Assert.Contains("panel_002", panelAssembly.DependsOn);
|
||||
Assert.Contains("panel_002", panelAssembly.Mates.Right ?? new List<string>());
|
||||
Assert.True(panelAssembly.Confidence >= 0.75);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_FallsBackToShelfDirection_WhenNoHardwareBindingExists()
|
||||
{
|
||||
var panels = new List<PanelInfo>
|
||||
{
|
||||
CreatePanel(
|
||||
"panel_left",
|
||||
"side_panel",
|
||||
new[] { 9.0, 280.0, 360.0 },
|
||||
new[] { 1.0, 0.0, 0.0 },
|
||||
new[] { 0.0, 0.0, 0.0 },
|
||||
new[] { 18.0, 560.0, 720.0 }),
|
||||
CreatePanel(
|
||||
"panel_right",
|
||||
"side_panel",
|
||||
new[] { 391.0, 280.0, 360.0 },
|
||||
new[] { 1.0, 0.0, 0.0 },
|
||||
new[] { 382.0, 0.0, 0.0 },
|
||||
new[] { 400.0, 560.0, 720.0 }),
|
||||
CreatePanel(
|
||||
"panel_shelf",
|
||||
"shelf",
|
||||
new[] { 200.0, 280.0, 359.0 },
|
||||
new[] { 0.0, 0.0, 1.0 },
|
||||
new[] { 18.0, 0.0, 350.0 },
|
||||
new[] { 382.0, 560.0, 368.0 }),
|
||||
};
|
||||
|
||||
var assembly = new AssemblySemanticAnalyzer().Analyze(
|
||||
panels,
|
||||
new List<HoleInfo>(),
|
||||
new List<HoleGroupInfo>(),
|
||||
new List<HardwareType>());
|
||||
|
||||
var shelf = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_shelf"));
|
||||
|
||||
Assert.Equal("drop_in", shelf.MountMode);
|
||||
Assert.Equal("top", shelf.AssemblyDirection);
|
||||
Assert.Contains("panel_left", shelf.TargetPanelIds);
|
||||
Assert.Contains("panel_right", shelf.TargetPanelIds);
|
||||
Assert.Contains("panel_left", shelf.Mates.Left ?? new List<string>());
|
||||
Assert.Contains("panel_right", shelf.Mates.Right ?? new List<string>());
|
||||
Assert.True(shelf.TravelDistance > 0);
|
||||
}
|
||||
|
||||
private static PanelInfo CreatePanel(
|
||||
string id,
|
||||
string panelType,
|
||||
double[] position,
|
||||
double[] normal,
|
||||
double[] boundingBoxMin,
|
||||
double[] boundingBoxMax)
|
||||
{
|
||||
return new PanelInfo
|
||||
{
|
||||
Id = id,
|
||||
PanelType = panelType,
|
||||
Position = position,
|
||||
Normal = normal,
|
||||
BoundingBox = new BoundingBox3D
|
||||
{
|
||||
Min = boundingBoxMin,
|
||||
Max = boundingBoxMax,
|
||||
},
|
||||
Size = new[]
|
||||
{
|
||||
Math.Round(boundingBoxMax[0] - boundingBoxMin[0], 2),
|
||||
Math.Round(boundingBoxMax[1] - boundingBoxMin[1], 2),
|
||||
Math.Round(boundingBoxMax[2] - boundingBoxMin[2], 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FurnitureExporter.Tests
|
||||
{
|
||||
public class CommandRegistrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fexport_DefaultsToSingleAssetExport()
|
||||
{
|
||||
var source = ReadCommandsSource();
|
||||
|
||||
Assert.Matches(CommandMethodPattern("FEXPORT", "ExportAssetDefault"), source);
|
||||
Assert.Matches(CommandMethodPattern("FEXPORTASSET", "ExportSingleAsset"), source);
|
||||
Assert.DoesNotMatch(CommandMethodPattern("FEXPORT", "ExportFurniture"), source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyProductBundleExport_UsesExplicitCompatibilityCommands()
|
||||
{
|
||||
var source = ReadCommandsSource();
|
||||
|
||||
Assert.Matches(CommandMethodPattern("FEXPORTPRODUCT", "ExportFurniture"), source);
|
||||
Assert.Matches(CommandMethodPattern("FEXPORTBUNDLE", "ExportFurnitureBundle"), source);
|
||||
}
|
||||
|
||||
private static string ReadCommandsSource()
|
||||
{
|
||||
var path = Path.GetFullPath(
|
||||
Path.Combine(AppContext.BaseDirectory, @"..\..\..\..\Commands.cs"));
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
private static Regex CommandMethodPattern(string commandName, string methodName)
|
||||
{
|
||||
return new Regex(
|
||||
$@"\[CommandMethod\(""{Regex.Escape(commandName)}""\)\]\s+public\s+void\s+{Regex.Escape(methodName)}\s*\(",
|
||||
RegexOptions.Multiline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<ImplicitUsings>false</ImplicitUsings>
|
||||
<LangVersion>10.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FurnitureExporter.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@ -1,89 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FurnitureExporter.Tests
|
||||
{
|
||||
public class MountPatternAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildMountPatterns_ForTwoHoleLine_RequiresTwoHoles()
|
||||
{
|
||||
var panel = CreatePanel("panel_line");
|
||||
var holes = new List<HoleInfo>
|
||||
{
|
||||
CreateHole("hole_a", "panel_line", new[] { -40.0, 0.0, 0.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_b", "panel_line", new[] { 40.0, 0.0, 0.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
};
|
||||
|
||||
var patterns = MountPatternAnalyzer.BuildMountPatterns(
|
||||
new[] { panel },
|
||||
holes,
|
||||
new List<HoleGroupInfo>());
|
||||
|
||||
var pattern = Assert.Single(patterns);
|
||||
Assert.Equal("line_2", pattern.PatternType);
|
||||
Assert.Equal(2, pattern.MinRequiredHoles);
|
||||
Assert.Equal(2, pattern.PreferredHoleCount);
|
||||
Assert.Single(panel.SnapPatterns);
|
||||
Assert.Equal(panel.MountPatterns, panel.SnapPatterns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMountPatterns_ForSymmetricRectangle_DefaultsToTwoRequiredHoles()
|
||||
{
|
||||
var panel = CreatePanel("panel_rect");
|
||||
var holes = new List<HoleInfo>
|
||||
{
|
||||
CreateHole("hole_a", "panel_rect", new[] { -40.0, 0.0, -20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_b", "panel_rect", new[] { 40.0, 0.0, -20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_c", "panel_rect", new[] { -40.0, 0.0, 20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_d", "panel_rect", new[] { 40.0, 0.0, 20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
};
|
||||
|
||||
var patterns = MountPatternAnalyzer.BuildMountPatterns(
|
||||
new[] { panel },
|
||||
holes,
|
||||
new List<HoleGroupInfo>());
|
||||
|
||||
var pattern = Assert.Single(patterns);
|
||||
Assert.Equal("rectangle_4", pattern.PatternType);
|
||||
Assert.Equal(2, pattern.MinRequiredHoles);
|
||||
Assert.Equal(4, pattern.PreferredHoleCount);
|
||||
Assert.True(pattern.Symmetry);
|
||||
Assert.Empty(pattern.RequiredHoleIds);
|
||||
Assert.Equal(4, pattern.OptionalHoleIds.Length);
|
||||
}
|
||||
|
||||
private static PanelInfo CreatePanel(string id)
|
||||
{
|
||||
return new PanelInfo
|
||||
{
|
||||
Id = id,
|
||||
Position = new[] { 0.0, 0.0, 0.0 },
|
||||
Normal = new[] { 0.0, 1.0, 0.0 },
|
||||
BoundingBox = new BoundingBox3D
|
||||
{
|
||||
Min = new[] { -300.0, -9.0, -300.0 },
|
||||
Max = new[] { 300.0, 9.0, 300.0 },
|
||||
},
|
||||
Size = new[] { 600.0, 18.0, 600.0 },
|
||||
};
|
||||
}
|
||||
|
||||
private static HoleInfo CreateHole(string id, string panelId, double[] position, double[] normal, string[] surfaceFaces)
|
||||
{
|
||||
return new HoleInfo
|
||||
{
|
||||
Id = id,
|
||||
PanelId = panelId,
|
||||
Position = position,
|
||||
Normal = normal,
|
||||
Diameter = 8.0,
|
||||
Radius = 4.0,
|
||||
Depth = 12.0,
|
||||
Type = "blind",
|
||||
SurfaceFaces = surfaceFaces,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
namespace FurnitureExporter.Tests
|
||||
{
|
||||
public class PanelFaceSemanticResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveSurfaceFaces_ForThroughHole_ReturnsOppositeFaces()
|
||||
{
|
||||
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
|
||||
new BoundingBox3D
|
||||
{
|
||||
Min = new[] { 0.0, 0.0, 0.0 },
|
||||
Max = new[] { 18.0, 560.0, 720.0 },
|
||||
},
|
||||
new[] { 9.0, 280.0, 360.0 },
|
||||
new[] { 1.0, 0.0, 0.0 },
|
||||
18.0);
|
||||
|
||||
Assert.Contains("left", faces);
|
||||
Assert.Contains("right", faces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSurfaceFaces_ForBlindHole_ReturnsSingleExteriorFace()
|
||||
{
|
||||
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
|
||||
new BoundingBox3D
|
||||
{
|
||||
Min = new[] { 0.0, 0.0, 0.0 },
|
||||
Max = new[] { 18.0, 560.0, 720.0 },
|
||||
},
|
||||
new[] { 4.0, 280.0, 360.0 },
|
||||
new[] { 1.0, 0.0, 0.0 },
|
||||
8.0);
|
||||
|
||||
Assert.Single(faces);
|
||||
Assert.Equal("left", faces[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSurfaceFaces_ForTopBottomThroughHole_ReturnsTopAndBottom()
|
||||
{
|
||||
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
|
||||
new BoundingBox3D
|
||||
{
|
||||
Min = new[] { 0.0, 0.0, 0.0 },
|
||||
Max = new[] { 600.0, 720.0, 18.0 },
|
||||
},
|
||||
new[] { 300.0, 360.0, 9.0 },
|
||||
new[] { 0.0, 0.0, 1.0 },
|
||||
18.0);
|
||||
|
||||
Assert.Contains("top", faces);
|
||||
Assert.Contains("bottom", faces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveSurfaceFaces_ForSideBlindHole_PrefersSingleSideFace()
|
||||
{
|
||||
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
|
||||
new BoundingBox3D
|
||||
{
|
||||
Min = new[] { 0.0, 0.0, 0.0 },
|
||||
Max = new[] { 600.0, 18.0, 720.0 },
|
||||
},
|
||||
new[] { 300.0, 4.0, 360.0 },
|
||||
new[] { 0.0, 1.0, 0.0 },
|
||||
8.0);
|
||||
|
||||
Assert.Single(faces);
|
||||
Assert.Equal("front", faces[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
namespace FurnitureExporter.Tests
|
||||
{
|
||||
public class SnapSurfaceBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildSnapSurfaces_FromBoundingBox_CreatesSixAxisFaces()
|
||||
{
|
||||
var panel = CreatePanel("panel_001");
|
||||
|
||||
var surfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel });
|
||||
|
||||
Assert.Equal(6, surfaces.Count);
|
||||
Assert.Equal(6, panel.SnapSurfaces.Count);
|
||||
|
||||
var top = Assert.Single(surfaces.Where(surface => surface.Face == "top"));
|
||||
Assert.Equal("panel_001_snap_top", top.Id);
|
||||
Assert.Equal("panel_001", top.PanelId);
|
||||
Assert.Equal(new[] { 0.0, 0.0, 1.0 }, top.Normal);
|
||||
Assert.Equal(new[] { 0.0, 0.0, 18.0 }, top.Center);
|
||||
Assert.Equal(18.0, top.Bounds.Min[2]);
|
||||
Assert.Equal(18.0, top.Bounds.Max[2]);
|
||||
Assert.Equal(1.0, top.Confidence);
|
||||
|
||||
var front = Assert.Single(surfaces.Where(surface => surface.Face == "front"));
|
||||
Assert.Equal(new[] { 0.0, -1.0, 0.0 }, front.Normal);
|
||||
Assert.Equal(-280.0, front.Center[1]);
|
||||
Assert.Equal(-280.0, front.Bounds.Min[1]);
|
||||
Assert.Equal(-280.0, front.Bounds.Max[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportData_ForSingleAsset_EmitsSnapFieldsAndOmitsAssembly()
|
||||
{
|
||||
var panel = CreatePanel("panel_001");
|
||||
var pattern = new MountPatternInfo
|
||||
{
|
||||
Id = "panel_001_mount_top_000",
|
||||
PanelId = "panel_001",
|
||||
MountFace = "top",
|
||||
PatternType = "line_2",
|
||||
HoleIds = new[] { "hole_a", "hole_b" },
|
||||
RequiredHoleIds = new[] { "hole_a", "hole_b" },
|
||||
MinRequiredHoles = 2,
|
||||
PreferredHoleCount = 2,
|
||||
Normal = new[] { 0.0, 0.0, 1.0 },
|
||||
Position = new[] { 0.0, 0.0, 18.0 },
|
||||
Confidence = 0.95,
|
||||
};
|
||||
panel.MountPatterns = new List<MountPatternInfo> { pattern };
|
||||
panel.SnapPatterns = panel.MountPatterns;
|
||||
|
||||
var exportData = new ExportData
|
||||
{
|
||||
ExportMode = "single_asset",
|
||||
Panels = new List<PanelInfo> { panel },
|
||||
MountPatterns = new List<MountPatternInfo> { pattern },
|
||||
SnapPatterns = new List<MountPatternInfo> { pattern },
|
||||
SnapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel }),
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(
|
||||
exportData,
|
||||
new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
});
|
||||
var root = JObject.Parse(json);
|
||||
|
||||
Assert.Null(root["assembly"]);
|
||||
Assert.DoesNotContain("\"mates\"", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("\"connections\"", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Single(Assert.IsType<JArray>(root["mountPatterns"]));
|
||||
Assert.Single(Assert.IsType<JArray>(root["snapPatterns"]));
|
||||
Assert.Equal(6, Assert.IsType<JArray>(root["snapSurfaces"]).Count);
|
||||
var panels = Assert.IsType<JArray>(root["panels"]);
|
||||
Assert.Equal(6, Assert.IsType<JArray>(panels[0]?["snapSurfaces"]).Count);
|
||||
}
|
||||
|
||||
private static PanelInfo CreatePanel(string id)
|
||||
{
|
||||
return new PanelInfo
|
||||
{
|
||||
Id = id,
|
||||
Position = new[] { 0.0, 0.0, 9.0 },
|
||||
Normal = new[] { 0.0, 0.0, 1.0 },
|
||||
BoundingBox = new BoundingBox3D
|
||||
{
|
||||
Min = new[] { -300.0, -280.0, 0.0 },
|
||||
Max = new[] { 300.0, 280.0, 18.0 },
|
||||
},
|
||||
Size = new[] { 600.0, 560.0, 18.0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
global using Xunit;
|
||||
@ -1,88 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<LangVersion>10.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<UseWPF>true</UseWPF>
|
||||
|
||||
<!-- AutoCAD 2024 插件配置 -->
|
||||
<AssemblyName>FurnitureExporter</AssemblyName>
|
||||
<RootNamespace>FurnitureExporter</RootNamespace>
|
||||
<Version>2.6.0</Version>
|
||||
<Authors>Furniture 3D System</Authors>
|
||||
<Description>家具 3D 模型导出插件 - 导出 GLB 模型和五金件位置数据</Description>
|
||||
|
||||
<!-- 不复制 AutoCAD 引用到输出目录(运行时由 AutoCAD 提供) -->
|
||||
<CopyLocalLockFileAssemblies>false</CopyLocalLockFileAssemblies>
|
||||
|
||||
<!-- 禁止警告 -->
|
||||
<NoWarn>$(NoWarn);CS0618;MSB3270</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
AutoCAD 安装路径自动检测
|
||||
优先级:
|
||||
1. 环境变量 ACAD_PATH
|
||||
2. 常见安装路径
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<!-- 从环境变量获取,如果未设置则尝试常见路径 -->
|
||||
<AcadPath Condition="'$(ACAD_PATH)' != ''">$(ACAD_PATH)</AcadPath>
|
||||
<AcadPath Condition="'$(AcadPath)' == '' AND Exists('C:\Program Files\Autodesk\AutoCAD 2024\accoremgd.dll')">C:\Program Files\Autodesk\AutoCAD 2024</AcadPath>
|
||||
<AcadPath Condition="'$(AcadPath)' == '' AND Exists('D:\Program Files\Autodesk\AutoCAD 2024\accoremgd.dll')">D:\Program Files\Autodesk\AutoCAD 2024</AcadPath>
|
||||
<AcadPath Condition="'$(AcadPath)' == '' AND Exists('E:\Program Files\Autodesk\AutoCAD 2024\accoremgd.dll')">E:\Program Files\Autodesk\AutoCAD 2024</AcadPath>
|
||||
<AcadPath Condition="'$(AcadPath)' == '' AND Exists('C:\Autodesk\AutoCAD 2024\accoremgd.dll')">C:\Autodesk\AutoCAD 2024</AcadPath>
|
||||
<AcadPath Condition="'$(AcadPath)' == '' AND Exists('D:\Autodesk\AutoCAD 2024\accoremgd.dll')">D:\Autodesk\AutoCAD 2024</AcadPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- AutoCAD .NET API 引用 -->
|
||||
<ItemGroup Condition="'$(AcadPath)' != ''">
|
||||
<Reference Include="accoremgd">
|
||||
<HintPath>$(AcadPath)\accoremgd.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="acdbmgd">
|
||||
<HintPath>$(AcadPath)\acdbmgd.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="acmgd">
|
||||
<HintPath>$(AcadPath)\acmgd.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<Reference Include="AcWindows">
|
||||
<HintPath>$(AcadPath)\AcWindows.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
<!-- BREP API (BoundaryRepresentation) -->
|
||||
<Reference Include="AcDbMgdBrep">
|
||||
<HintPath>$(AcadPath)\AcDbMgdBrep.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 无需额外 NuGet 包,使用纯 .NET Framework 4.8 和 AutoCAD 自带库 -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="FurnitureExporter.Tests\**\*.cs" />
|
||||
<EmbeddedResource Remove="FurnitureExporter.Tests\**\*" />
|
||||
<None Remove="FurnitureExporter.Tests\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 使用 AutoCAD 自带的 Newtonsoft.Json -->
|
||||
<ItemGroup Condition="'$(AcadPath)' != ''">
|
||||
<Reference Include="Newtonsoft.Json">
|
||||
<HintPath>$(AcadPath)\Newtonsoft.Json.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 编译前检查 -->
|
||||
<Target Name="CheckAcadPath" BeforeTargets="Build">
|
||||
<Error Condition="'$(AcadPath)' == ''"
|
||||
Text="找不到 AutoCAD 2024 安装路径!请设置环境变量 ACAD_PATH 指向 AutoCAD 2024 安装目录,例如:
 set ACAD_PATH=D:\Program Files\Autodesk\AutoCAD 2024
 dotnet build" />
|
||||
<Message Importance="High" Text="使用 AutoCAD 路径: $(AcadPath)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@ -1,716 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Autodesk.AutoCAD.DatabaseServices;
|
||||
using Autodesk.AutoCAD.Geometry;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 五金件分析器 - 提取五金件的位置和几何特征
|
||||
/// v2.2: 增加图层名称识别和五金件类型推断
|
||||
/// </summary>
|
||||
public class HardwareAnalyzer
|
||||
{
|
||||
private readonly Database _db;
|
||||
private Point3d _originOffset = Point3d.Origin;
|
||||
|
||||
// 几何特征匹配容差 (mm)
|
||||
private const double SIZE_TOLERANCE = 0.5;
|
||||
private const double VOLUME_TOLERANCE = 10;
|
||||
|
||||
// 关联判断参数
|
||||
private const double HOLE_ASSOCIATION_TOLERANCE = 5; // 五金件与孔位关联容差 5mm
|
||||
private const double PANEL_ASSOCIATION_TOLERANCE = 50; // 五金件与板材关联容差 50mm
|
||||
|
||||
// 图层名称到五金件类型的映射规则(按关键词匹配)
|
||||
private static readonly Dictionary<string, string> LayerKeywordMapping = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// 铰链相关
|
||||
{ "铰链", "hinge" },
|
||||
{ "合页", "hinge" },
|
||||
{ "门转轴", "hinge" },
|
||||
{ "hinge", "hinge" },
|
||||
|
||||
// 螺丝相关
|
||||
{ "螺丝", "screw" },
|
||||
{ "螺钉", "screw" },
|
||||
{ "自攻", "screw" },
|
||||
{ "内六角", "screw" },
|
||||
{ "screw", "screw" },
|
||||
|
||||
// 连接件相关
|
||||
{ "三合一", "connector" },
|
||||
{ "偏心", "connector" },
|
||||
{ "连接件", "connector" },
|
||||
{ "connector", "connector" },
|
||||
|
||||
// 层板托相关
|
||||
{ "层板托", "shelf_pin" },
|
||||
{ "搁板销", "shelf_pin" },
|
||||
{ "托", "shelf_pin" },
|
||||
|
||||
// 抽屉滑轨相关
|
||||
{ "滑轨", "drawer_slide" },
|
||||
{ "导轨", "drawer_slide" },
|
||||
{ "抽屉", "drawer_slide" },
|
||||
{ "slide", "drawer_slide" },
|
||||
|
||||
// 门吸/反弹器
|
||||
{ "反弹", "push_latch" },
|
||||
{ "门吸", "magnetic_catch" },
|
||||
{ "吸铁", "magnetic_catch" },
|
||||
|
||||
// 拉手相关
|
||||
{ "拉手", "handle" },
|
||||
{ "把手", "handle" },
|
||||
{ "handle", "handle" },
|
||||
|
||||
// 孔/透气孔
|
||||
{ "透气孔", "vent" },
|
||||
{ "过线孔", "cable_hole" },
|
||||
{ "走线孔", "cable_hole" },
|
||||
};
|
||||
|
||||
public HardwareAnalyzer(Database db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据图层名称推断五金件类型
|
||||
/// </summary>
|
||||
public static string InferHardwareTypeFromLayer(string layerName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(layerName))
|
||||
return "unknown";
|
||||
|
||||
foreach (var kvp in LayerKeywordMapping)
|
||||
{
|
||||
if (layerName.IndexOf(kvp.Key, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return kvp.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分析五金件,提取位置和几何特征
|
||||
/// </summary>
|
||||
/// <param name="hardwareIds">五金件实体 ID 数组</param>
|
||||
/// <param name="originOffset">原点偏移(可选,用于将坐标归一化到世界原点)</param>
|
||||
public ExportData Analyze(ObjectId[] hardwareIds, Point3d? originOffset = null)
|
||||
{
|
||||
_originOffset = originOffset ?? Point3d.Origin;
|
||||
|
||||
var exportData = new ExportData
|
||||
{
|
||||
HardwareTypes = new List<HardwareType>()
|
||||
};
|
||||
|
||||
if (hardwareIds.Length == 0)
|
||||
return exportData;
|
||||
|
||||
using var tr = _db.TransactionManager.StartTransaction();
|
||||
|
||||
// 收集所有五金件的特征和位置
|
||||
var instances = new List<(GeometrySignature Signature, HardwareInstance Instance)>();
|
||||
|
||||
foreach (var id in hardwareIds)
|
||||
{
|
||||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||||
if (ent == null) continue;
|
||||
|
||||
var result = ExtractHardwareInfo(ent);
|
||||
if (result != null)
|
||||
{
|
||||
instances.Add(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// 按几何特征分组
|
||||
var groups = GroupBySignature(instances);
|
||||
|
||||
// 转换为导出格式
|
||||
int typeIndex = 0;
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var hwType = new HardwareType
|
||||
{
|
||||
TypeId = $"type_{typeIndex++:D3}",
|
||||
Signature = group.Key,
|
||||
Instances = group.Value
|
||||
};
|
||||
exportData.HardwareTypes.Add(hwType);
|
||||
}
|
||||
|
||||
tr.Commit();
|
||||
return exportData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分析五金件并关联板材和孔位(v2.2 增强版)
|
||||
/// </summary>
|
||||
/// <param name="hardwareIds">五金件实体 ID 数组</param>
|
||||
/// <param name="panels">板材信息列表</param>
|
||||
/// <param name="holes">孔位信息列表</param>
|
||||
/// <param name="originOffset">原点偏移</param>
|
||||
public ExportData AnalyzeWithAssociation(
|
||||
ObjectId[] hardwareIds,
|
||||
List<PanelInfo> panels,
|
||||
List<HoleInfo> holes,
|
||||
Point3d? originOffset = null)
|
||||
{
|
||||
var exportData = Analyze(hardwareIds, originOffset);
|
||||
|
||||
// 为每个五金件实例添加关联信息
|
||||
foreach (var hwType in exportData.HardwareTypes)
|
||||
{
|
||||
foreach (var instance in hwType.Instances)
|
||||
{
|
||||
AssociateWithPanelsAndHoles(instance, panels, holes);
|
||||
}
|
||||
|
||||
// v2.2: 分析图层信息,推断五金件类型
|
||||
var layerCounts = hwType.Instances
|
||||
.Where(i => !string.IsNullOrEmpty(i.SourceLayer))
|
||||
.GroupBy(i => i.SourceLayer!)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
if (layerCounts.Count > 0)
|
||||
{
|
||||
// 取数量最多的图层作为主要图层
|
||||
var primaryLayer = layerCounts.OrderByDescending(kvp => kvp.Value).First().Key;
|
||||
hwType.PrimaryLayerName = primaryLayer;
|
||||
hwType.InferredHardwareType = InferHardwareTypeFromLayer(primaryLayer);
|
||||
}
|
||||
}
|
||||
|
||||
// v2.2: 生成图层信息映射
|
||||
GenerateLayerInfo(exportData, hardwareIds);
|
||||
|
||||
// v2.2: 生成五金件按图层汇总
|
||||
GenerateHardwareSummary(exportData);
|
||||
|
||||
return exportData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成图层信息映射(v2.2 新增)
|
||||
/// </summary>
|
||||
private void GenerateLayerInfo(ExportData exportData, ObjectId[] hardwareIds)
|
||||
{
|
||||
using var tr = _db.TransactionManager.StartTransaction();
|
||||
|
||||
var layerEntityCount = new Dictionary<string, int>();
|
||||
|
||||
foreach (var id in hardwareIds)
|
||||
{
|
||||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||||
if (ent == null) continue;
|
||||
|
||||
var layerName = ent.Layer;
|
||||
if (!layerEntityCount.ContainsKey(layerName))
|
||||
layerEntityCount[layerName] = 0;
|
||||
layerEntityCount[layerName]++;
|
||||
}
|
||||
|
||||
foreach (var kvp in layerEntityCount)
|
||||
{
|
||||
var layerName = kvp.Key;
|
||||
var hwType = InferHardwareTypeFromLayer(layerName);
|
||||
|
||||
exportData.Layers[layerName] = new LayerInfo
|
||||
{
|
||||
Name = layerName,
|
||||
Category = hwType != "unknown" ? "hardware" : "other",
|
||||
HardwareType = hwType != "unknown" ? hwType : null,
|
||||
EntityCount = kvp.Value
|
||||
};
|
||||
}
|
||||
|
||||
tr.Commit();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成五金件按图层汇总(v2.2 新增)
|
||||
/// </summary>
|
||||
private void GenerateHardwareSummary(ExportData exportData)
|
||||
{
|
||||
var summaryDict = new Dictionary<string, HardwareLayerSummary>();
|
||||
|
||||
int instanceIndex = 0;
|
||||
foreach (var hwType in exportData.HardwareTypes)
|
||||
{
|
||||
foreach (var instance in hwType.Instances)
|
||||
{
|
||||
var layerName = instance.SourceLayer ?? "未知图层";
|
||||
var hwTypeName = InferHardwareTypeFromLayer(layerName);
|
||||
var key = $"{layerName}|{hwTypeName}";
|
||||
|
||||
if (!summaryDict.ContainsKey(key))
|
||||
{
|
||||
summaryDict[key] = new HardwareLayerSummary
|
||||
{
|
||||
LayerName = layerName,
|
||||
HardwareType = hwTypeName,
|
||||
Count = 0,
|
||||
InstanceIds = new List<string>()
|
||||
};
|
||||
}
|
||||
|
||||
summaryDict[key].Count++;
|
||||
summaryDict[key].InstanceIds.Add($"{hwType.TypeId}_inst_{instanceIndex++}");
|
||||
}
|
||||
}
|
||||
|
||||
exportData.HardwareSummary = summaryDict.Values
|
||||
.OrderBy(s => s.HardwareType)
|
||||
.ThenBy(s => s.LayerName)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为五金件实例关联板材和孔位
|
||||
/// </summary>
|
||||
private void AssociateWithPanelsAndHoles(HardwareInstance instance, List<PanelInfo> panels, List<HoleInfo> holes)
|
||||
{
|
||||
var hwPosition = new Point3d(instance.Position.X, instance.Position.Y, instance.Position.Z);
|
||||
|
||||
var candidateHoles = holes
|
||||
.Select((hole) => new
|
||||
{
|
||||
Hole = hole,
|
||||
Distance = hwPosition.DistanceTo(new Point3d(hole.Position[0], hole.Position[1], hole.Position[2]))
|
||||
})
|
||||
.OrderBy(item => item.Distance)
|
||||
.ToList();
|
||||
|
||||
var matchedHoles = candidateHoles
|
||||
.Where(item => item.Distance <= HOLE_ASSOCIATION_TOLERANCE)
|
||||
.ToList();
|
||||
|
||||
if (matchedHoles.Count > 0)
|
||||
{
|
||||
var groupedHoleMatch = matchedHoles
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Hole.HoleGroupId))
|
||||
.GroupBy(item => item.Hole.HoleGroupId!, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ThenBy(group => group.Min(item => item.Distance))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (groupedHoleMatch != null)
|
||||
{
|
||||
var representative = groupedHoleMatch
|
||||
.OrderBy(item => item.Distance)
|
||||
.First();
|
||||
var groupHoles = holes
|
||||
.Where(hole => string.Equals(hole.HoleGroupId, groupedHoleMatch.Key, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
instance.NearestHoleId = representative.Hole.Id;
|
||||
instance.DistanceToNearestHole = Math.Round(representative.Distance, 2);
|
||||
instance.PrimaryPanelId = representative.Hole.PanelId;
|
||||
instance.SecondaryPanelId = FindSecondaryPanelId(representative.Hole.PanelId, hwPosition, panels);
|
||||
instance.BoundHoleGroupId = groupedHoleMatch.Key;
|
||||
instance.BoundHoleIds = groupHoles.Select(hole => hole.Id).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
instance.BindingMode = "hole_group";
|
||||
instance.BindingConfidence = Math.Round(ComputeHoleBindingConfidence(representative.Distance, groupHoles.Count), 4);
|
||||
instance.BindingReason = $"优先按孔组 {groupedHoleMatch.Key} 绑定";
|
||||
instance.NearbyPanelIds = BuildNearbyPanelIds(instance.PrimaryPanelId, instance.SecondaryPanelId, hwPosition, panels);
|
||||
return;
|
||||
}
|
||||
|
||||
var nearestHole = matchedHoles[0];
|
||||
instance.NearestHoleId = nearestHole.Hole.Id;
|
||||
instance.DistanceToNearestHole = Math.Round(nearestHole.Distance, 2);
|
||||
instance.PrimaryPanelId = nearestHole.Hole.PanelId;
|
||||
instance.SecondaryPanelId = FindSecondaryPanelId(nearestHole.Hole.PanelId, hwPosition, panels);
|
||||
instance.BoundHoleIds = new[] { nearestHole.Hole.Id };
|
||||
instance.BindingMode = "hole";
|
||||
instance.BindingConfidence = Math.Round(ComputeHoleBindingConfidence(nearestHole.Distance, 1), 4);
|
||||
instance.BindingReason = $"优先按单孔 {nearestHole.Hole.Id} 绑定";
|
||||
instance.NearbyPanelIds = BuildNearbyPanelIds(instance.PrimaryPanelId, instance.SecondaryPanelId, hwPosition, panels);
|
||||
return;
|
||||
}
|
||||
|
||||
var nearbyPanels = FindNearbyPanels(hwPosition, panels);
|
||||
if (nearbyPanels.Count > 0)
|
||||
{
|
||||
instance.PrimaryPanelId = nearbyPanels[0].Panel.Id;
|
||||
instance.SecondaryPanelId = nearbyPanels.Count > 1 ? nearbyPanels[1].Panel.Id : null;
|
||||
instance.NearbyPanelIds = nearbyPanels.Select(item => item.Panel.Id).Take(4).ToArray();
|
||||
instance.BindingMode = nearbyPanels[0].UsedBoundingBox ? "panel_bbox" : "panel_center";
|
||||
instance.BindingConfidence = Math.Round(ComputePanelBindingConfidence(nearbyPanels[0].Distance, nearbyPanels[0].UsedBoundingBox), 4);
|
||||
instance.BindingReason = nearbyPanels[0].UsedBoundingBox
|
||||
? "未命中孔位,按板件包围盒邻近绑定"
|
||||
: "未命中孔位,按板件中心邻近绑定";
|
||||
return;
|
||||
}
|
||||
|
||||
instance.BindingMode = "none";
|
||||
instance.BindingConfidence = 0;
|
||||
instance.BindingReason = "未找到可关联的孔位或板件";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断点是否在扩展边界框内
|
||||
/// </summary>
|
||||
private bool IsPointInBox(Point3d point, Point3d min, Point3d max)
|
||||
{
|
||||
return point.X >= min.X && point.X <= max.X
|
||||
&& point.Y >= min.Y && point.Y <= max.Y
|
||||
&& point.Z >= min.Z && point.Z <= max.Z;
|
||||
}
|
||||
|
||||
private List<(PanelInfo Panel, double Distance, bool UsedBoundingBox)> FindNearbyPanels(Point3d hwPosition, List<PanelInfo> panels)
|
||||
{
|
||||
var nearbyPanels = new List<(PanelInfo Panel, double Distance, bool UsedBoundingBox)>();
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
if (panel.BoundingBox != null)
|
||||
{
|
||||
var minPt = new Point3d(panel.BoundingBox.Min[0], panel.BoundingBox.Min[1], panel.BoundingBox.Min[2]);
|
||||
var maxPt = new Point3d(panel.BoundingBox.Max[0], panel.BoundingBox.Max[1], panel.BoundingBox.Max[2]);
|
||||
|
||||
var expandedMin = new Point3d(
|
||||
minPt.X - PANEL_ASSOCIATION_TOLERANCE,
|
||||
minPt.Y - PANEL_ASSOCIATION_TOLERANCE,
|
||||
minPt.Z - PANEL_ASSOCIATION_TOLERANCE
|
||||
);
|
||||
var expandedMax = new Point3d(
|
||||
maxPt.X + PANEL_ASSOCIATION_TOLERANCE,
|
||||
maxPt.Y + PANEL_ASSOCIATION_TOLERANCE,
|
||||
maxPt.Z + PANEL_ASSOCIATION_TOLERANCE
|
||||
);
|
||||
|
||||
if (IsPointInBox(hwPosition, expandedMin, expandedMax))
|
||||
{
|
||||
nearbyPanels.Add((panel, DistanceToExpandedBox(hwPosition, expandedMin, expandedMax), true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var panelCenter = new Point3d(panel.Position[0], panel.Position[1], panel.Position[2]);
|
||||
var distance = hwPosition.DistanceTo(panelCenter);
|
||||
var maxPanelDim = Math.Max(panel.Size[0], Math.Max(panel.Size[1], panel.Size[2]));
|
||||
|
||||
if (distance < maxPanelDim / 2 + PANEL_ASSOCIATION_TOLERANCE)
|
||||
{
|
||||
nearbyPanels.Add((panel, distance, false));
|
||||
}
|
||||
}
|
||||
|
||||
return nearbyPanels
|
||||
.OrderBy(item => item.Distance)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private string[]? BuildNearbyPanelIds(string? primaryPanelId, string? secondaryPanelId, Point3d hwPosition, List<PanelInfo> panels)
|
||||
{
|
||||
var ordered = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(primaryPanelId))
|
||||
{
|
||||
ordered.Add(primaryPanelId!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(secondaryPanelId) &&
|
||||
!ordered.Contains(secondaryPanelId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ordered.Add(secondaryPanelId!);
|
||||
}
|
||||
|
||||
foreach (var panel in FindNearbyPanels(hwPosition, panels))
|
||||
{
|
||||
if (ordered.Contains(panel.Panel.Id, StringComparer.OrdinalIgnoreCase))
|
||||
continue;
|
||||
ordered.Add(panel.Panel.Id);
|
||||
if (ordered.Count >= 4)
|
||||
break;
|
||||
}
|
||||
|
||||
return ordered.Count > 0 ? ordered.ToArray() : null;
|
||||
}
|
||||
|
||||
private string? FindSecondaryPanelId(string primaryPanelId, Point3d hwPosition, List<PanelInfo> panels)
|
||||
{
|
||||
foreach (var candidate in FindNearbyPanels(hwPosition, panels))
|
||||
{
|
||||
if (string.Equals(candidate.Panel.Id, primaryPanelId, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
return candidate.Panel.Id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double ComputeHoleBindingConfidence(double distance, int holeCount)
|
||||
{
|
||||
var distanceFactor = Math.Max(0.0, 1.0 - distance / HOLE_ASSOCIATION_TOLERANCE);
|
||||
var groupBonus = holeCount > 1 ? 0.03 : 0.0;
|
||||
return Math.Min(0.99, 0.92 + distanceFactor * 0.05 + groupBonus);
|
||||
}
|
||||
|
||||
private static double ComputePanelBindingConfidence(double distance, bool usedBoundingBox)
|
||||
{
|
||||
var distanceFactor = Math.Max(0.0, 1.0 - distance / PANEL_ASSOCIATION_TOLERANCE);
|
||||
var baseConfidence = usedBoundingBox ? 0.58 : 0.45;
|
||||
var range = usedBoundingBox ? 0.22 : 0.18;
|
||||
return Math.Min(0.88, baseConfidence + distanceFactor * range);
|
||||
}
|
||||
|
||||
private static double DistanceToExpandedBox(Point3d point, Point3d min, Point3d max)
|
||||
{
|
||||
double dx = 0, dy = 0, dz = 0;
|
||||
|
||||
if (point.X < min.X) dx = min.X - point.X;
|
||||
else if (point.X > max.X) dx = point.X - max.X;
|
||||
|
||||
if (point.Y < min.Y) dy = min.Y - point.Y;
|
||||
else if (point.Y > max.Y) dy = point.Y - max.Y;
|
||||
|
||||
if (point.Z < min.Z) dz = min.Z - point.Z;
|
||||
else if (point.Z > max.Z) dz = point.Z - max.Z;
|
||||
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提取单个五金件的信息
|
||||
/// </summary>
|
||||
private (GeometrySignature Signature, HardwareInstance Instance)? ExtractHardwareInfo(Entity entity)
|
||||
{
|
||||
if (entity is Solid3d solid)
|
||||
{
|
||||
return ExtractFromSolid(solid);
|
||||
}
|
||||
else if (entity is BlockReference blockRef)
|
||||
{
|
||||
return ExtractFromBlockReference(blockRef);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 3D Solid 提取信息
|
||||
/// </summary>
|
||||
private (GeometrySignature Signature, HardwareInstance Instance)? ExtractFromSolid(Solid3d solid)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ext = solid.GeometricExtents;
|
||||
var size = ext.MaxPoint - ext.MinPoint;
|
||||
var center = ext.MinPoint + (size / 2);
|
||||
|
||||
// 应用原点偏移
|
||||
var offsetCenter = new Point3d(
|
||||
center.X + _originOffset.X,
|
||||
center.Y + _originOffset.Y,
|
||||
center.Z + _originOffset.Z
|
||||
);
|
||||
|
||||
// 规范化尺寸(排序,使比较一致)
|
||||
var dims = new[] { size.X, size.Y, size.Z }.OrderByDescending(d => d).ToArray();
|
||||
|
||||
// 获取体积
|
||||
double volume = 0;
|
||||
try
|
||||
{
|
||||
var mass = solid.MassProperties;
|
||||
volume = mass.Volume;
|
||||
}
|
||||
catch { }
|
||||
|
||||
var signature = new GeometrySignature
|
||||
{
|
||||
Width = Math.Round(dims[0], 2),
|
||||
Height = Math.Round(dims[1], 2),
|
||||
Depth = Math.Round(dims[2], 2),
|
||||
Volume = Math.Round(volume, 2)
|
||||
};
|
||||
|
||||
var instance = new HardwareInstance
|
||||
{
|
||||
Position = new Position3D
|
||||
{
|
||||
X = Math.Round(offsetCenter.X, 2),
|
||||
Y = Math.Round(offsetCenter.Y, 2),
|
||||
Z = Math.Round(offsetCenter.Z, 2)
|
||||
},
|
||||
Rotation = ExtractRotation(solid),
|
||||
SourceLayer = solid.Layer,
|
||||
SourceHandle = solid.Handle.ToString()
|
||||
};
|
||||
|
||||
return (signature, instance);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从图块引用提取信息
|
||||
/// </summary>
|
||||
private (GeometrySignature Signature, HardwareInstance Instance)? ExtractFromBlockReference(BlockReference blockRef)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ext = blockRef.GeometricExtents;
|
||||
var size = ext.MaxPoint - ext.MinPoint;
|
||||
|
||||
// 应用原点偏移到位置
|
||||
var offsetPosition = new Point3d(
|
||||
blockRef.Position.X + _originOffset.X,
|
||||
blockRef.Position.Y + _originOffset.Y,
|
||||
blockRef.Position.Z + _originOffset.Z
|
||||
);
|
||||
|
||||
// 规范化尺寸
|
||||
var dims = new[] { size.X, size.Y, size.Z }.OrderByDescending(d => d).ToArray();
|
||||
|
||||
var signature = new GeometrySignature
|
||||
{
|
||||
Width = Math.Round(dims[0], 2),
|
||||
Height = Math.Round(dims[1], 2),
|
||||
Depth = Math.Round(dims[2], 2),
|
||||
Volume = 0, // 图块无法直接获取体积
|
||||
BlockName = blockRef.Name
|
||||
};
|
||||
|
||||
var instance = new HardwareInstance
|
||||
{
|
||||
Position = new Position3D
|
||||
{
|
||||
X = Math.Round(offsetPosition.X, 2),
|
||||
Y = Math.Round(offsetPosition.Y, 2),
|
||||
Z = Math.Round(offsetPosition.Z, 2)
|
||||
},
|
||||
Rotation = new Rotation3D
|
||||
{
|
||||
X = Math.Round(blockRef.Rotation, 4),
|
||||
Y = 0,
|
||||
Z = 0
|
||||
},
|
||||
Scale = new Scale3D
|
||||
{
|
||||
X = Math.Round(blockRef.ScaleFactors.X, 4),
|
||||
Y = Math.Round(blockRef.ScaleFactors.Y, 4),
|
||||
Z = Math.Round(blockRef.ScaleFactors.Z, 4)
|
||||
},
|
||||
SourceLayer = blockRef.Layer,
|
||||
SourceHandle = blockRef.Handle.ToString(),
|
||||
SourceBlockName = blockRef.Name
|
||||
};
|
||||
|
||||
return (signature, instance);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提取实体的旋转信息
|
||||
/// </summary>
|
||||
private Rotation3D ExtractRotation(Solid3d solid)
|
||||
{
|
||||
// 简化处理:从边界框方向推断旋转
|
||||
// 实际应用中可能需要更复杂的分析
|
||||
return new Rotation3D { X = 0, Y = 0, Z = 0 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按几何特征分组
|
||||
/// </summary>
|
||||
private Dictionary<GeometrySignature, List<HardwareInstance>> GroupBySignature(
|
||||
List<(GeometrySignature Signature, HardwareInstance Instance)> instances)
|
||||
{
|
||||
var groups = new Dictionary<GeometrySignature, List<HardwareInstance>>(
|
||||
new SignatureComparer(SIZE_TOLERANCE, VOLUME_TOLERANCE));
|
||||
|
||||
foreach (var (sig, inst) in instances)
|
||||
{
|
||||
// 查找已有的匹配组
|
||||
GeometrySignature? matchedKey = null;
|
||||
foreach (var key in groups.Keys)
|
||||
{
|
||||
if (SignaturesMatch(key, sig))
|
||||
{
|
||||
matchedKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedKey != null)
|
||||
{
|
||||
groups[matchedKey].Add(inst);
|
||||
}
|
||||
else
|
||||
{
|
||||
groups[sig] = new List<HardwareInstance> { inst };
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断两个特征是否匹配
|
||||
/// </summary>
|
||||
private bool SignaturesMatch(GeometrySignature a, GeometrySignature b)
|
||||
{
|
||||
return Math.Abs(a.Width - b.Width) <= SIZE_TOLERANCE
|
||||
&& Math.Abs(a.Height - b.Height) <= SIZE_TOLERANCE
|
||||
&& Math.Abs(a.Depth - b.Depth) <= SIZE_TOLERANCE
|
||||
&& (a.Volume == 0 || b.Volume == 0 || Math.Abs(a.Volume - b.Volume) <= VOLUME_TOLERANCE);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 几何特征比较器
|
||||
/// </summary>
|
||||
internal class SignatureComparer : IEqualityComparer<GeometrySignature>
|
||||
{
|
||||
private readonly double _sizeTol;
|
||||
private readonly double _volTol;
|
||||
|
||||
public SignatureComparer(double sizeTolerance, double volumeTolerance)
|
||||
{
|
||||
_sizeTol = sizeTolerance;
|
||||
_volTol = volumeTolerance;
|
||||
}
|
||||
|
||||
public bool Equals(GeometrySignature? x, GeometrySignature? y)
|
||||
{
|
||||
if (x == null || y == null) return false;
|
||||
|
||||
return Math.Abs(x.Width - y.Width) <= _sizeTol
|
||||
&& Math.Abs(x.Height - y.Height) <= _sizeTol
|
||||
&& Math.Abs(x.Depth - y.Depth) <= _sizeTol;
|
||||
}
|
||||
|
||||
public int GetHashCode(GeometrySignature obj)
|
||||
{
|
||||
// 使用粗略的哈希,让 Equals 做精确比较
|
||||
var w = (int)(Math.Round(obj.Width / 5) * 5);
|
||||
var h = (int)(Math.Round(obj.Height / 5) * 5);
|
||||
var d = (int)(Math.Round(obj.Depth / 5) * 5);
|
||||
unchecked
|
||||
{
|
||||
int hash = 17;
|
||||
hash = hash * 31 + w;
|
||||
hash = hash * 31 + h;
|
||||
hash = hash * 31 + d;
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,383 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Autodesk.AutoCAD.DatabaseServices;
|
||||
using Autodesk.AutoCAD.Geometry;
|
||||
using Autodesk.AutoCAD.BoundaryRepresentation;
|
||||
using BrepFace = Autodesk.AutoCAD.BoundaryRepresentation.Face;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 孔位检测器 - 从板材实体中提取圆柱形孔位
|
||||
/// 通过分析 Solid3d 的 Brep(边界表示)来识别布尔运算产生的孔
|
||||
/// </summary>
|
||||
public class HoleDetector
|
||||
{
|
||||
private readonly Database _db;
|
||||
private Point3d _originOffset = Point3d.Origin;
|
||||
|
||||
// 孔位检测参数
|
||||
private const double MIN_HOLE_DIAMETER = 2; // 最小孔径 2mm
|
||||
private const double MAX_HOLE_DIAMETER = 100; // 最大孔径 100mm
|
||||
private const double MIN_HOLE_DEPTH = 3; // 最小孔深 3mm
|
||||
|
||||
public HoleDetector(Database db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从板材实体中检测所有孔位
|
||||
/// </summary>
|
||||
/// <param name="solidId">板材实体 ID</param>
|
||||
/// <param name="panelId">板材 ID</param>
|
||||
/// <param name="originOffset">原点偏移</param>
|
||||
/// <returns>孔位信息列表</returns>
|
||||
public List<HoleInfo> DetectHoles(ObjectId solidId, string panelId, Point3d? originOffset = null)
|
||||
{
|
||||
_originOffset = originOffset ?? Point3d.Origin;
|
||||
var holes = new List<HoleInfo>();
|
||||
|
||||
using var tr = _db.TransactionManager.StartTransaction();
|
||||
|
||||
var solid = tr.GetObject(solidId, OpenMode.ForRead) as Solid3d;
|
||||
if (solid == null)
|
||||
{
|
||||
tr.Commit();
|
||||
return holes;
|
||||
}
|
||||
|
||||
holes = DetectHolesFromSolid(solid, panelId);
|
||||
|
||||
tr.Commit();
|
||||
return holes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从多个板材实体中检测所有孔位
|
||||
/// </summary>
|
||||
public List<HoleInfo> DetectAllHoles(ObjectId[] solidIds, List<PanelInfo> panels, Point3d? originOffset = null)
|
||||
{
|
||||
_originOffset = originOffset ?? Point3d.Origin;
|
||||
var allHoles = new List<HoleInfo>();
|
||||
|
||||
using var tr = _db.TransactionManager.StartTransaction();
|
||||
|
||||
for (int i = 0; i < solidIds.Length && i < panels.Count; i++)
|
||||
{
|
||||
var solid = tr.GetObject(solidIds[i], OpenMode.ForRead) as Solid3d;
|
||||
if (solid == null) continue;
|
||||
|
||||
var holes = DetectHolesFromSolid(solid, panels[i].Id);
|
||||
allHoles.AddRange(holes);
|
||||
}
|
||||
|
||||
tr.Commit();
|
||||
return allHoles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从单个 Solid3d 实体检测孔位
|
||||
/// </summary>
|
||||
private List<HoleInfo> DetectHolesFromSolid(Solid3d solid, string panelId)
|
||||
{
|
||||
var holes = new List<HoleInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
// 使用 Brep API 分析实体表面
|
||||
using var brep = new Brep(solid);
|
||||
|
||||
// 收集所有圆柱形面
|
||||
var cylindricalFaces = new List<(BrepFace Face, Cylinder Cylinder)>();
|
||||
|
||||
foreach (var face in brep.Faces)
|
||||
{
|
||||
try
|
||||
{
|
||||
// face.Surface 常常是 ExternalBoundedSurface 等包装类型,需剥离到基础几何表面
|
||||
var surface = BrepSurfaceUtils.Unwrap(face.Surface);
|
||||
if (surface is Cylinder cylinder)
|
||||
{
|
||||
// 验证是否为内凹面(孔)而非外凸面
|
||||
if (IsConcaveCylinder(face, cylinder))
|
||||
{
|
||||
cylindricalFaces.Add((face, cylinder));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 某些面可能无法获取表面信息,跳过
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 将相同圆柱配对(孔通常有两个端面)
|
||||
var processedFaces = new HashSet<int>();
|
||||
int holeIndex = 0;
|
||||
|
||||
foreach (var (face, cylinder) in cylindricalFaces)
|
||||
{
|
||||
if (processedFaces.Contains(face.GetHashCode()))
|
||||
continue;
|
||||
|
||||
var hole = ExtractHoleInfo(face, cylinder, solid, panelId, holeIndex);
|
||||
if (hole != null && IsValidHole(hole))
|
||||
{
|
||||
holes.Add(hole);
|
||||
holeIndex++;
|
||||
}
|
||||
|
||||
processedFaces.Add(face.GetHashCode());
|
||||
}
|
||||
|
||||
// 去重(合并相近的孔)
|
||||
holes = MergeNearbyHoles(holes);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[HoleDetector] Error: {ex.Message}");
|
||||
}
|
||||
|
||||
return holes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断圆柱面是否为内凹(孔)
|
||||
/// </summary>
|
||||
private bool IsConcaveCylinder(BrepFace face, Cylinder cylinder)
|
||||
{
|
||||
// 简化判断:根据圆柱半径和板材尺寸判断是否可能是孔
|
||||
// 圆柱半径在合理范围内即认为是孔
|
||||
var radius = cylinder.Radius;
|
||||
return radius >= MIN_HOLE_DIAMETER / 2 && radius <= MAX_HOLE_DIAMETER / 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从圆柱面提取孔位信息
|
||||
/// </summary>
|
||||
private HoleInfo? ExtractHoleInfo(BrepFace face, Cylinder cylinder, Solid3d solid, string panelId, int index)
|
||||
{
|
||||
try
|
||||
{
|
||||
BoundingBox3D? panelBoundingBox = null;
|
||||
try
|
||||
{
|
||||
var ext = solid.GeometricExtents;
|
||||
panelBoundingBox = new BoundingBox3D
|
||||
{
|
||||
Min = new[]
|
||||
{
|
||||
Math.Round(ext.MinPoint.X + _originOffset.X, 2),
|
||||
Math.Round(ext.MinPoint.Y + _originOffset.Y, 2),
|
||||
Math.Round(ext.MinPoint.Z + _originOffset.Z, 2),
|
||||
},
|
||||
Max = new[]
|
||||
{
|
||||
Math.Round(ext.MaxPoint.X + _originOffset.X, 2),
|
||||
Math.Round(ext.MaxPoint.Y + _originOffset.Y, 2),
|
||||
Math.Round(ext.MaxPoint.Z + _originOffset.Z, 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
panelBoundingBox = null;
|
||||
}
|
||||
|
||||
var radius = cylinder.Radius;
|
||||
var diameter = radius * 2;
|
||||
|
||||
// 获取圆柱的轴向(孔的方向)
|
||||
var axis = cylinder.AxisOfSymmetry.GetNormal();
|
||||
|
||||
// 获取孔的位置
|
||||
var origin = cylinder.Origin;
|
||||
|
||||
// 计算孔深度
|
||||
var depth = CalculateHoleDepth(face, solid, cylinder);
|
||||
|
||||
// 应用原点偏移
|
||||
var offsetPosition = new Point3d(
|
||||
origin.X + _originOffset.X,
|
||||
origin.Y + _originOffset.Y,
|
||||
origin.Z + _originOffset.Z
|
||||
);
|
||||
|
||||
// 判断孔类型(通孔/盲孔)
|
||||
string holeType = DetermineHoleType(face, solid, cylinder, depth);
|
||||
|
||||
return new HoleInfo
|
||||
{
|
||||
Id = $"{panelId}_hole_{index:D3}",
|
||||
Position = new double[]
|
||||
{
|
||||
Math.Round(offsetPosition.X, 2),
|
||||
Math.Round(offsetPosition.Y, 2),
|
||||
Math.Round(offsetPosition.Z, 2)
|
||||
},
|
||||
Normal = new double[]
|
||||
{
|
||||
Math.Round(axis.X, 4),
|
||||
Math.Round(axis.Y, 4),
|
||||
Math.Round(axis.Z, 4)
|
||||
},
|
||||
Diameter = Math.Round(diameter, 2),
|
||||
Depth = Math.Round(depth, 2),
|
||||
PanelId = panelId,
|
||||
Type = holeType,
|
||||
Radius = Math.Round(radius, 2),
|
||||
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
|
||||
panelBoundingBox,
|
||||
new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z },
|
||||
new[] { axis.X, axis.Y, axis.Z },
|
||||
depth)
|
||||
};
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[HoleDetector] ExtractHoleInfo error: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算孔深度
|
||||
/// </summary>
|
||||
private double CalculateHoleDepth(BrepFace face, Solid3d solid, Cylinder cylinder)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 使用板材厚度作为孔深度的参考
|
||||
var solidExt = solid.GeometricExtents;
|
||||
var solidSize = solidExt.MaxPoint - solidExt.MinPoint;
|
||||
var thickness = Math.Min(solidSize.X, Math.Min(solidSize.Y, solidSize.Z));
|
||||
|
||||
// 默认返回板材厚度(假设为通孔)
|
||||
return thickness;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断孔类型(通孔/盲孔)
|
||||
/// </summary>
|
||||
private string DetermineHoleType(BrepFace face, Solid3d solid, Cylinder cylinder, double depth)
|
||||
{
|
||||
try
|
||||
{
|
||||
var solidExt = solid.GeometricExtents;
|
||||
var solidSize = solidExt.MaxPoint - solidExt.MinPoint;
|
||||
|
||||
// 获取板材厚度(最小尺寸)
|
||||
var thickness = Math.Min(solidSize.X, Math.Min(solidSize.Y, solidSize.Z));
|
||||
|
||||
// 如果孔深度接近板材厚度(误差 2mm),认为是通孔
|
||||
if (Math.Abs(depth - thickness) < 2)
|
||||
{
|
||||
return "through";
|
||||
}
|
||||
|
||||
return "blind";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "blind";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证孔位是否有效
|
||||
/// </summary>
|
||||
private bool IsValidHole(HoleInfo hole)
|
||||
{
|
||||
// 检查直径范围
|
||||
if (hole.Diameter < MIN_HOLE_DIAMETER || hole.Diameter > MAX_HOLE_DIAMETER)
|
||||
return false;
|
||||
|
||||
// 检查深度
|
||||
if (hole.Depth < MIN_HOLE_DEPTH)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合并相近的孔位(去重)
|
||||
/// </summary>
|
||||
private List<HoleInfo> MergeNearbyHoles(List<HoleInfo> holes)
|
||||
{
|
||||
if (holes.Count <= 1)
|
||||
return holes;
|
||||
|
||||
var merged = new List<HoleInfo>();
|
||||
var processed = new HashSet<int>();
|
||||
const double MERGE_TOLERANCE = 1.0; // 1mm 容差
|
||||
|
||||
for (int i = 0; i < holes.Count; i++)
|
||||
{
|
||||
if (processed.Contains(i))
|
||||
continue;
|
||||
|
||||
var current = holes[i];
|
||||
processed.Add(i);
|
||||
|
||||
// 查找相近的孔
|
||||
for (int j = i + 1; j < holes.Count; j++)
|
||||
{
|
||||
if (processed.Contains(j))
|
||||
continue;
|
||||
|
||||
var other = holes[j];
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(current.Position[0] - other.Position[0], 2) +
|
||||
Math.Pow(current.Position[1] - other.Position[1], 2) +
|
||||
Math.Pow(current.Position[2] - other.Position[2], 2)
|
||||
);
|
||||
|
||||
// 如果位置相近且直径相同,认为是同一个孔
|
||||
if (distance < MERGE_TOLERANCE && Math.Abs(current.Diameter - other.Diameter) < 0.5)
|
||||
{
|
||||
processed.Add(j);
|
||||
// 保留深度较大的那个
|
||||
if (other.Depth > current.Depth)
|
||||
{
|
||||
current = other;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged.Add(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据直径推断孔类型(五金件类型)
|
||||
/// </summary>
|
||||
public static string InferHoleUsage(double diameter)
|
||||
{
|
||||
// 从大到小、从具体到模糊排列,避免匹配重叠
|
||||
if (diameter >= 34 && diameter <= 36) return "hinge"; // 铰链杯孔
|
||||
if (diameter >= 20 && diameter <= 100) return "cable"; // 过线孔
|
||||
if (diameter >= 14 && diameter <= 16) return "connector"; // 偏心轮孔
|
||||
if (diameter >= 9.5 && diameter <= 10.5) return "dowel_10"; // 木榫孔 10mm
|
||||
if (diameter >= 7.5 && diameter <= 8.5) return "dowel_8"; // 木榫孔 8mm
|
||||
if (diameter >= 7 && diameter <= 9) return "connector_small"; // 三合一孔
|
||||
if (diameter >= 5.5 && diameter <= 6.5) return "dowel_6"; // 木榫孔 6mm
|
||||
|
||||
// v2.3:孔径 4.8mm 在实际导出中非常常见(更像 M6 的导孔/预钻孔),
|
||||
// 仅靠“孔径”无法稳定区分“层板托孔”和“螺丝导孔”,因此这里返回更中性的用途,
|
||||
// 具体候选螺丝直径由 FastenerGuesser 在 hole.SuggestedFasteners 中给出。
|
||||
if (diameter >= 4.5 && diameter <= 5.3) return "screw_or_shelf_pin";
|
||||
if (diameter >= 3.0 && diameter < 4.5) return "screw"; // 螺丝预钻孔(常见给 M4/M5)
|
||||
if (diameter >= 2.0 && diameter < 3.0) return "micro_screw"; // 小螺丝/定位孔(保守兜底)
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@ -1,213 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 孔位分组(逻辑孔位)
|
||||
///
|
||||
/// 背景:
|
||||
/// - CAD 几何层面:偏心件(偏心螺丝/三合一连接件)常常表现为“两个互相垂直的圆柱孔”(例如 8mm 侧孔 + 12/15mm 主孔);
|
||||
/// - 业务层面:设计师/工艺人员通常把这一套当成 1 个“孔位”,用于计数、展示与五金匹配;
|
||||
/// - 因此这里把相关几何孔合并成 HoleGroup,同时在 HoleInfo 上写入 HoleGroupId。
|
||||
/// </summary>
|
||||
public static class HoleGrouping
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 HoleGroupId 计算“逻辑孔位数量”
|
||||
/// </summary>
|
||||
public static int ComputeLogicalHoleCount(IEnumerable<HoleInfo> holes)
|
||||
{
|
||||
return holes
|
||||
.Select(h => string.IsNullOrWhiteSpace(h.HoleGroupId) ? h.Id : h.HoleGroupId!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Count();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 识别并生成“偏心螺丝孔位”分组
|
||||
///
|
||||
/// 规则(基于当前样例与常见家具连接件):
|
||||
/// - 小孔:直径约 8mm(7.5~8.5)
|
||||
/// - 主孔:直径约 12~16mm(11~16.5),与小孔法向大致垂直
|
||||
/// - 空间距离:中心距离在阈值内(默认 45mm)
|
||||
/// </summary>
|
||||
public static List<HoleGroupInfo> BuildEccentricScrewGroups(IReadOnlyList<HoleInfo> holes)
|
||||
{
|
||||
var groups = new List<HoleGroupInfo>();
|
||||
|
||||
// 每个板件独立分组,避免跨板件误配
|
||||
var byPanel = holes
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h.PanelId))
|
||||
.GroupBy(h => h.PanelId, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var panelGroup in byPanel)
|
||||
{
|
||||
var panelId = panelGroup.Key;
|
||||
var panelHoles = panelGroup.ToList();
|
||||
|
||||
var small = panelHoles
|
||||
.Where(h => h.Diameter >= 7.5 && h.Diameter <= 8.5)
|
||||
.ToList();
|
||||
|
||||
var main = panelHoles
|
||||
.Where(h => h.Diameter >= 11.0 && h.Diameter <= 16.5)
|
||||
.ToList();
|
||||
|
||||
if (small.Count == 0 || main.Count == 0)
|
||||
continue;
|
||||
|
||||
var usedSmall = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var usedMain = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
int groupIndex = 0;
|
||||
|
||||
foreach (var mainHole in main)
|
||||
{
|
||||
if (usedMain.Contains(mainHole.Id))
|
||||
continue;
|
||||
|
||||
var best = FindBestSmallHole(mainHole, small, usedSmall, maxDistanceMm: 45.0);
|
||||
if (best == null)
|
||||
continue;
|
||||
|
||||
usedMain.Add(mainHole.Id);
|
||||
usedSmall.Add(best.Id);
|
||||
|
||||
var groupId = $"{panelId}_group_{groupIndex:D3}";
|
||||
groupIndex++;
|
||||
|
||||
var dist = Distance(mainHole.Position, best.Position);
|
||||
var dot = Math.Abs(Dot(mainHole.Normal, best.Normal));
|
||||
var confidence = ComputeConfidence(dist, dot, maxDistanceMm: 45.0);
|
||||
|
||||
// 记录分组
|
||||
var holeIds = new[] { mainHole.Id, best.Id };
|
||||
var group = new HoleGroupInfo
|
||||
{
|
||||
Id = groupId,
|
||||
PanelId = panelId,
|
||||
GroupType = "eccentric_screw",
|
||||
HoleIds = holeIds,
|
||||
Position = mainHole.Position,
|
||||
Normal = mainHole.Normal,
|
||||
Confidence = confidence,
|
||||
Reason = $"主孔直径 {mainHole.Diameter:0.#}mm + 侧孔直径 {best.Diameter:0.#}mm,距离 {dist:0.#}mm,法向点积 {dot:0.00}",
|
||||
MountFace = mainHole.SurfaceFaces.FirstOrDefault(face => !string.IsNullOrWhiteSpace(face)) ?? "unknown",
|
||||
SurfaceFaces = mainHole.SurfaceFaces
|
||||
.Concat(best.SurfaceFaces ?? Array.Empty<string>())
|
||||
.Where(face => !string.IsNullOrWhiteSpace(face))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray(),
|
||||
};
|
||||
groups.Add(group);
|
||||
|
||||
// 标记成员孔:同一个逻辑孔位
|
||||
MarkGroupMember(mainHole, groupId, "eccentric_screw", role: "main");
|
||||
MarkGroupMember(best, groupId, "eccentric_screw", role: "side");
|
||||
|
||||
// 偏心孔位场景下,单纯按直径推断(dowel_8/unknown)容易误导,这里统一覆盖成 eccentric_screw
|
||||
mainHole.InferredUsage = "eccentric_screw";
|
||||
best.InferredUsage = "eccentric_screw";
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把分组写回到 PanelInfo,方便按板件查看
|
||||
/// </summary>
|
||||
public static void AttachGroupsToPanels(IReadOnlyList<PanelInfo> panels, IReadOnlyList<HoleGroupInfo> groups)
|
||||
{
|
||||
var map = groups
|
||||
.GroupBy(g => g.PanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
if (map.TryGetValue(panel.Id, out var list))
|
||||
{
|
||||
panel.HoleGroups = list;
|
||||
}
|
||||
else
|
||||
{
|
||||
panel.HoleGroups = new List<HoleGroupInfo>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HoleInfo? FindBestSmallHole(
|
||||
HoleInfo mainHole,
|
||||
IReadOnlyList<HoleInfo> smallCandidates,
|
||||
HashSet<string> usedSmall,
|
||||
double maxDistanceMm)
|
||||
{
|
||||
HoleInfo? best = null;
|
||||
double bestScore = double.NegativeInfinity;
|
||||
|
||||
foreach (var s in smallCandidates)
|
||||
{
|
||||
if (usedSmall.Contains(s.Id))
|
||||
continue;
|
||||
|
||||
// 法向需要接近垂直:|dot| 越小越好
|
||||
var dot = Math.Abs(Dot(mainHole.Normal, s.Normal));
|
||||
if (dot > 0.35) // 约 70 度以内就不算“明显垂直”
|
||||
continue;
|
||||
|
||||
var dist = Distance(mainHole.Position, s.Position);
|
||||
if (dist > maxDistanceMm)
|
||||
continue;
|
||||
|
||||
// 评分:越近越好,越垂直越好
|
||||
var score = (1.0 - (dist / maxDistanceMm)) + (1.0 - dot);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static void MarkGroupMember(HoleInfo hole, string groupId, string groupType, string role)
|
||||
{
|
||||
hole.HoleGroupId = groupId;
|
||||
hole.HoleGroupType = groupType;
|
||||
hole.HoleGroupRole = role;
|
||||
}
|
||||
|
||||
private static double ComputeConfidence(double dist, double absDot, double maxDistanceMm)
|
||||
{
|
||||
// dist: 0 -> 1, maxDistance -> 0
|
||||
var distScore = Clamp(1.0 - (dist / maxDistanceMm), 0.0, 1.0);
|
||||
// absDot: 0(垂直) -> 1, 1(平行) -> 0
|
||||
var orthoScore = Clamp(1.0 - absDot, 0.0, 1.0);
|
||||
return Math.Round(0.6 * orthoScore + 0.4 * distScore, 4);
|
||||
}
|
||||
|
||||
private static double Distance(double[] a, double[] b)
|
||||
{
|
||||
var dx = a[0] - b[0];
|
||||
var dy = a[1] - b[1];
|
||||
var dz = a[2] - b[2];
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
private static double Dot(double[] a, double[] b)
|
||||
{
|
||||
if (a.Length < 3 || b.Length < 3)
|
||||
return 0;
|
||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
}
|
||||
|
||||
private static double Clamp(double value, double min, double max)
|
||||
{
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,710 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Autodesk.AutoCAD.DatabaseServices;
|
||||
using Autodesk.AutoCAD.Geometry;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 标注孔位检测器(Fallback)
|
||||
///
|
||||
/// 背景:
|
||||
/// - 真实 3D 打孔(布尔运算形成的圆柱面)可以通过 Brep 检测(HoleDetector/PanelAnalyzer 已实现)
|
||||
/// - 但很多家具 CAD 图纸的“孔位”并不会在 Solid3d 上做减法,而是用 Circle/Block 等 2D/3D 标注表示
|
||||
///
|
||||
/// 这里提供一个降级方案:
|
||||
/// - 扫描模型空间中的 Circle(以及 Block 内部的 Circle)
|
||||
/// - 将符合孔径范围的圆,按“法向量对齐 + 贴近板件表面”的规则关联到最近板件
|
||||
/// - 最终写入 PanelInfo.Holes,供导出 JSON 使用
|
||||
/// </summary>
|
||||
public sealed class HoleMarkerDetector
|
||||
{
|
||||
private readonly Database _db;
|
||||
|
||||
// 和 HoleDetector 保持一致的孔径范围(单位:mm)
|
||||
private const double MinHoleDiameter = 2.0;
|
||||
private const double MaxHoleDiameter = 100.0;
|
||||
|
||||
// 关联容差(单位:mm)
|
||||
private const double FaceTolerance = 2.0; // 认为“在板面上”的容差
|
||||
private const double BoxTolerance = 2.0; // 点在包围盒范围内的容差
|
||||
|
||||
// 法向量匹配阈值(|dot| 越接近 1 越平行)
|
||||
private const double NormalDotThreshold = 0.90;
|
||||
|
||||
// 搜索范围扩展(避免标注略超出实体包围盒)
|
||||
private const double SearchMargin = 50.0;
|
||||
|
||||
public HoleMarkerDetector(Database db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从图纸标注(Circle/Block 内 Circle)检测孔位,并挂载到 panels[*].Holes
|
||||
/// </summary>
|
||||
/// <param name="panels">板件列表(来自 PanelAnalyzer)</param>
|
||||
/// <param name="originOffset">导出使用的原点偏移(与板件/模型一致)</param>
|
||||
/// <param name="searchMin">用于限制扫描的模型边界最小点(未偏移前的世界坐标)</param>
|
||||
/// <param name="searchMax">用于限制扫描的模型边界最大点(未偏移前的世界坐标)</param>
|
||||
/// <returns>本次新增的孔位列表(已合并去重,已写入 panels)</returns>
|
||||
public List<HoleInfo> DetectAndAttach(
|
||||
List<PanelInfo> panels,
|
||||
Point3d originOffset,
|
||||
Point3d searchMin,
|
||||
Point3d searchMax,
|
||||
IReadOnlyCollection<string>? eligiblePanelIds = null)
|
||||
{
|
||||
// 可关联的板件(要求有包围盒)
|
||||
var candidates = panels
|
||||
.Where(p => p.BoundingBox != null)
|
||||
.Where(p => eligiblePanelIds == null || eligiblePanelIds.Contains(p.Id))
|
||||
.Select(p => new PanelCandidate(p))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return new List<HoleInfo>();
|
||||
|
||||
var searchExtents = ExpandExtents(new Extents3d(searchMin, searchMax), SearchMargin);
|
||||
var detected = new List<HoleInfo>();
|
||||
|
||||
using var tr = _db.TransactionManager.StartTransaction();
|
||||
var bt = (BlockTable)tr.GetObject(_db.BlockTableId, OpenMode.ForRead);
|
||||
var ms = (BlockTableRecord)tr.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForRead);
|
||||
|
||||
// 缓存:同一个块定义只解析一次
|
||||
var blockCircleCache = new Dictionary<ObjectId, List<BlockCircleMarker>>();
|
||||
|
||||
foreach (ObjectId id in ms)
|
||||
{
|
||||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||||
if (ent == null) continue;
|
||||
|
||||
if (!TryGetEntityCenter(ent, out var entCenter))
|
||||
{
|
||||
// Circle/Block 之外的实体不处理
|
||||
}
|
||||
else
|
||||
{
|
||||
// 用中心点快速过滤搜索范围(避免全图扫描带来的性能损耗)
|
||||
if (!IsPointInExtents(entCenter, searchExtents))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ent is Circle circle)
|
||||
{
|
||||
TryAddCircleHole(circle.Center, circle.Normal, circle.Radius, originOffset, candidates, detected);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ent is DBPoint point)
|
||||
{
|
||||
// 点类型孔位:常见于“孔位中心点/打孔标注”
|
||||
if (LooksLikeHoleHint(point.Layer))
|
||||
{
|
||||
TryAddPointHole(point.Position, originOffset, candidates, point.Layer, detected);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ent is BlockReference br)
|
||||
{
|
||||
// 先用块引用自身包围盒做二次过滤
|
||||
if (!TryGetGeometricExtents(br, out var brExt) || !Intersects(brExt, searchExtents))
|
||||
continue;
|
||||
|
||||
var defId = br.BlockTableRecord;
|
||||
if (!blockCircleCache.TryGetValue(defId, out var markers))
|
||||
{
|
||||
markers = ExtractCirclesFromBlockDefinition(defId, tr);
|
||||
blockCircleCache[defId] = markers;
|
||||
}
|
||||
|
||||
if (markers.Count == 0)
|
||||
{
|
||||
// 有些孔位标注是“十字/点阵/文字”的块,没有 Circle;此时把块插入点当作孔中心
|
||||
if (TryGetBlockName(br, tr, out var blockName))
|
||||
{
|
||||
var hint = $"{br.Layer} {blockName}";
|
||||
if (LooksLikeHoleHint(hint))
|
||||
{
|
||||
TryAddPointHole(br.Position, originOffset, candidates, hint, detected);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var mat = br.BlockTransform;
|
||||
var scale = EstimateBlockUniformScale(br);
|
||||
|
||||
foreach (var m in markers)
|
||||
{
|
||||
var worldCenter = m.Center.TransformBy(mat);
|
||||
if (!IsPointInExtents(worldCenter, searchExtents))
|
||||
continue;
|
||||
|
||||
// 法向量只做旋转缩放变换(Vector3d.TransformBy 不受平移影响)
|
||||
var worldNormal = m.Normal.TransformBy(mat);
|
||||
if (worldNormal.Length > 1e-9)
|
||||
worldNormal = worldNormal.GetNormal();
|
||||
|
||||
var worldRadius = m.Radius * scale;
|
||||
TryAddCircleHole(worldCenter, worldNormal, worldRadius, originOffset, candidates, detected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tr.Commit();
|
||||
|
||||
// 合并去重(同一孔可能被重复标注/重复扫描出来)
|
||||
var merged = MergeNearbyHoles(detected);
|
||||
|
||||
// 写入 panels:按 panelId 分组、补齐 ID
|
||||
var byPanel = merged
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h.PanelId))
|
||||
.GroupBy(h => h.PanelId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
if (!byPanel.TryGetValue(panel.Id, out var holes))
|
||||
continue;
|
||||
|
||||
// 从现有孔位数量继续编号,避免覆盖已有 ID
|
||||
var startIndex = panel.Holes.Count;
|
||||
for (int i = 0; i < holes.Count; i++)
|
||||
{
|
||||
holes[i].Id = $"{panel.Id}_hole_{(startIndex + i):D3}";
|
||||
}
|
||||
|
||||
panel.Holes.AddRange(holes);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static bool TryGetEntityCenter(Entity ent, out Point3d center)
|
||||
{
|
||||
// 用于快速范围过滤
|
||||
if (ent is Circle c)
|
||||
{
|
||||
center = c.Center;
|
||||
return true;
|
||||
}
|
||||
if (ent is BlockReference br)
|
||||
{
|
||||
center = br.Position;
|
||||
return true;
|
||||
}
|
||||
if (ent is DBPoint pt)
|
||||
{
|
||||
center = pt.Position;
|
||||
return true;
|
||||
}
|
||||
|
||||
center = Point3d.Origin;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetGeometricExtents(Entity ent, out Extents3d ext)
|
||||
{
|
||||
try
|
||||
{
|
||||
ext = ent.GeometricExtents;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ext = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPointInExtents(Point3d p, Extents3d e)
|
||||
{
|
||||
return p.X >= e.MinPoint.X && p.X <= e.MaxPoint.X
|
||||
&& p.Y >= e.MinPoint.Y && p.Y <= e.MaxPoint.Y
|
||||
&& p.Z >= e.MinPoint.Z && p.Z <= e.MaxPoint.Z;
|
||||
}
|
||||
|
||||
private static bool Intersects(Extents3d a, Extents3d b)
|
||||
{
|
||||
return a.MinPoint.X <= b.MaxPoint.X && a.MaxPoint.X >= b.MinPoint.X
|
||||
&& a.MinPoint.Y <= b.MaxPoint.Y && a.MaxPoint.Y >= b.MinPoint.Y
|
||||
&& a.MinPoint.Z <= b.MaxPoint.Z && a.MaxPoint.Z >= b.MinPoint.Z;
|
||||
}
|
||||
|
||||
private static Extents3d ExpandExtents(Extents3d e, double margin)
|
||||
{
|
||||
var min = new Point3d(e.MinPoint.X - margin, e.MinPoint.Y - margin, e.MinPoint.Z - margin);
|
||||
var max = new Point3d(e.MaxPoint.X + margin, e.MaxPoint.Y + margin, e.MaxPoint.Z + margin);
|
||||
return new Extents3d(min, max);
|
||||
}
|
||||
|
||||
private static double EstimateBlockUniformScale(BlockReference br)
|
||||
{
|
||||
try
|
||||
{
|
||||
var s = br.ScaleFactors;
|
||||
var sx = Math.Abs(s.X);
|
||||
var sy = Math.Abs(s.Y);
|
||||
var sz = Math.Abs(s.Z);
|
||||
// 取平均值作为近似;家具孔位标注通常不会做非均匀缩放
|
||||
return (sx + sy + sz) / 3.0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BlockCircleMarker> ExtractCirclesFromBlockDefinition(ObjectId blockDefId, Transaction tr)
|
||||
{
|
||||
var markers = new List<BlockCircleMarker>();
|
||||
try
|
||||
{
|
||||
var btr = (BlockTableRecord)tr.GetObject(blockDefId, OpenMode.ForRead);
|
||||
foreach (ObjectId id in btr)
|
||||
{
|
||||
var ent = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||||
if (ent is Circle c)
|
||||
{
|
||||
var d = c.Radius * 2;
|
||||
if (d < MinHoleDiameter || d > MaxHoleDiameter)
|
||||
continue;
|
||||
|
||||
markers.Add(new BlockCircleMarker(c.Center, c.Normal, c.Radius));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 解析失败则忽略
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
private static bool TryGetBlockName(BlockReference br, Transaction tr, out string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var btr = (BlockTableRecord)tr.GetObject(br.BlockTableRecord, OpenMode.ForRead);
|
||||
name = btr.Name ?? "";
|
||||
return !string.IsNullOrWhiteSpace(name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
name = "";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LooksLikeHoleHint(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return false;
|
||||
|
||||
var lower = text!.ToLowerInvariant();
|
||||
// 中文/英文常见命名:孔、孔位、hole、drill
|
||||
return text.Contains("孔") || lower.Contains("hole") || lower.Contains("drill");
|
||||
}
|
||||
|
||||
private static bool TryInferDiameterFromHint(string? text, out double diameter)
|
||||
{
|
||||
diameter = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return false;
|
||||
|
||||
// 提取文本里的数字(例如:8、8.0、35 等),只接受 2~100 范围
|
||||
foreach (Match m in Regex.Matches(text, @"(\d+(?:\.\d+)?)"))
|
||||
{
|
||||
if (!double.TryParse(m.Groups[1].Value, out var v))
|
||||
continue;
|
||||
|
||||
if (v >= MinHoleDiameter && v <= MaxHoleDiameter)
|
||||
{
|
||||
diameter = v;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void TryAddPointHole(
|
||||
Point3d position,
|
||||
Point3d originOffset,
|
||||
List<PanelCandidate> panels,
|
||||
string? hint,
|
||||
List<HoleInfo> output)
|
||||
{
|
||||
var p = new Point3d(position.X + originOffset.X, position.Y + originOffset.Y, position.Z + originOffset.Z);
|
||||
|
||||
var binding = FindBestPanelForPoint(p, panels);
|
||||
if (binding == null)
|
||||
return;
|
||||
|
||||
var axis = binding.PanelCandidate.Normal;
|
||||
if (axis.Length > 1e-9) axis = axis.GetNormal();
|
||||
|
||||
var depth = binding.PanelCandidate.Panel.Size.Length >= 3 ? binding.PanelCandidate.Panel.Size[2] : 18.0;
|
||||
|
||||
double diameter;
|
||||
if (!TryInferDiameterFromHint(hint, out diameter))
|
||||
{
|
||||
// 未知直径也导出(主要用于“孔位-五金件关联”,直径只影响用途推断)
|
||||
diameter = 0;
|
||||
}
|
||||
|
||||
output.Add(new HoleInfo
|
||||
{
|
||||
Id = "",
|
||||
PanelId = binding.PanelCandidate.Panel.Id,
|
||||
Position = new[] { Math.Round(p.X, 2), Math.Round(p.Y, 2), Math.Round(p.Z, 2) },
|
||||
Normal = new[] { Math.Round(axis.X, 4), Math.Round(axis.Y, 4), Math.Round(axis.Z, 4) },
|
||||
Diameter = Math.Round(diameter, 2),
|
||||
Radius = Math.Round(diameter / 2.0, 2),
|
||||
Depth = Math.Round(depth, 2),
|
||||
Type = "blind",
|
||||
DetectionSource = "marker",
|
||||
BindingSource = binding.BindingSource,
|
||||
BindingConfidence = binding.Confidence,
|
||||
BindingReason = binding.BindingReason,
|
||||
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
|
||||
binding.PanelCandidate.Panel.BoundingBox,
|
||||
new[] { p.X, p.Y, p.Z },
|
||||
new[] { axis.X, axis.Y, axis.Z },
|
||||
depth),
|
||||
});
|
||||
}
|
||||
|
||||
private static void TryAddCircleHole(
|
||||
Point3d center,
|
||||
Vector3d normal,
|
||||
double radius,
|
||||
Point3d originOffset,
|
||||
List<PanelCandidate> panels,
|
||||
List<HoleInfo> output)
|
||||
{
|
||||
var diameter = radius * 2.0;
|
||||
if (diameter < MinHoleDiameter || diameter > MaxHoleDiameter)
|
||||
return;
|
||||
|
||||
var axis = normal;
|
||||
if (axis.Length < 1e-9)
|
||||
return;
|
||||
axis = axis.GetNormal();
|
||||
|
||||
// 坐标对齐:面板/模型已经应用 originOffset,这里也要一致
|
||||
var p = new Point3d(center.X + originOffset.X, center.Y + originOffset.Y, center.Z + originOffset.Z);
|
||||
|
||||
var binding = FindBestPanelForHole(p, axis, panels);
|
||||
if (binding == null)
|
||||
return;
|
||||
|
||||
var depth = binding.PanelCandidate.Panel.Size.Length >= 3 ? binding.PanelCandidate.Panel.Size[2] : 18.0;
|
||||
|
||||
output.Add(new HoleInfo
|
||||
{
|
||||
// ID 在最后统一补齐(避免与现有孔位冲突)
|
||||
Id = "",
|
||||
PanelId = binding.PanelCandidate.Panel.Id,
|
||||
Position = new[] { Math.Round(p.X, 2), Math.Round(p.Y, 2), Math.Round(p.Z, 2) },
|
||||
Normal = new[] { Math.Round(axis.X, 4), Math.Round(axis.Y, 4), Math.Round(axis.Z, 4) },
|
||||
Diameter = Math.Round(diameter, 2),
|
||||
Radius = Math.Round(radius, 2),
|
||||
Depth = Math.Round(depth, 2),
|
||||
Type = "blind",
|
||||
DetectionSource = "marker",
|
||||
BindingSource = binding.BindingSource,
|
||||
BindingConfidence = binding.Confidence,
|
||||
BindingReason = binding.BindingReason,
|
||||
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
|
||||
binding.PanelCandidate.Panel.BoundingBox,
|
||||
new[] { p.X, p.Y, p.Z },
|
||||
new[] { axis.X, axis.Y, axis.Z },
|
||||
depth),
|
||||
});
|
||||
}
|
||||
|
||||
private static PanelBindingResult? FindBestPanelForHole(Point3d p, Vector3d axis, List<PanelCandidate> panels)
|
||||
{
|
||||
PanelCandidate? best = null;
|
||||
string bestBindingSource = "marker_box";
|
||||
string bestBindingReason = "按标注孔包围盒退化匹配板件";
|
||||
double bestScore = double.MaxValue;
|
||||
|
||||
// 两阶段:优先贴面(更像真实孔位标注),否则退回到“在包围盒内”
|
||||
for (int pass = 0; pass < 2; pass++)
|
||||
{
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
var dot = Math.Abs(panel.Normal.DotProduct(axis));
|
||||
if (dot < NormalDotThreshold)
|
||||
continue;
|
||||
|
||||
if (pass == 0)
|
||||
{
|
||||
if (!IsPointOnPanelFace(p, panel.Extents, panel.Normal, FaceTolerance, BoxTolerance))
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!IsPointInsideBox(p, panel.Extents, BoxTolerance))
|
||||
continue;
|
||||
}
|
||||
|
||||
// 以到包围盒的距离作为评分(越小越好)
|
||||
var dist = DistancePointToBox(p, panel.Extents);
|
||||
// 同等距离下,dot 越大越好
|
||||
var score = dist + (1.0 - dot) * 10.0;
|
||||
|
||||
if (score < bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = panel;
|
||||
bestBindingSource = pass == 0 ? "marker_face" : "marker_box";
|
||||
bestBindingReason = pass == 0
|
||||
? "按标注孔贴面匹配板件"
|
||||
: "按标注孔包围盒退化匹配板件";
|
||||
}
|
||||
}
|
||||
|
||||
if (best != null)
|
||||
return new PanelBindingResult(
|
||||
best,
|
||||
bestBindingSource,
|
||||
ComputeBindingConfidence(bestScore, pass == 0)
|
||||
,
|
||||
bestBindingReason
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static PanelBindingResult? FindBestPanelForPoint(Point3d p, List<PanelCandidate> panels)
|
||||
{
|
||||
PanelCandidate? best = null;
|
||||
double bestDist = double.MaxValue;
|
||||
|
||||
// 优先贴面(更像真实孔位标注)
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
if (!IsPointOnPanelFace(p, panel.Extents, panel.Normal, FaceTolerance, BoxTolerance))
|
||||
continue;
|
||||
|
||||
var dist = DistancePointToBox(p, panel.Extents);
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
best = panel;
|
||||
}
|
||||
}
|
||||
|
||||
if (best != null)
|
||||
return new PanelBindingResult(
|
||||
best,
|
||||
"marker_face",
|
||||
ComputeBindingConfidence(bestDist, true),
|
||||
"按标注点贴面匹配板件"
|
||||
);
|
||||
|
||||
// 退化:在包围盒内
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
if (!IsPointInsideBox(p, panel.Extents, BoxTolerance))
|
||||
continue;
|
||||
|
||||
var dist = DistancePointToBox(p, panel.Extents);
|
||||
if (dist < bestDist)
|
||||
{
|
||||
bestDist = dist;
|
||||
best = panel;
|
||||
}
|
||||
}
|
||||
|
||||
if (best == null)
|
||||
return null;
|
||||
|
||||
return new PanelBindingResult(
|
||||
best,
|
||||
"marker_box",
|
||||
ComputeBindingConfidence(bestDist, false),
|
||||
"按标注点包围盒退化匹配板件"
|
||||
);
|
||||
}
|
||||
|
||||
private static double ComputeBindingConfidence(double score, bool faceMatched)
|
||||
{
|
||||
var normalized = Math.Max(0.0, 1.0 - score / 10.0);
|
||||
var baseConfidence = faceMatched ? 0.88 : 0.58;
|
||||
var range = faceMatched ? 0.12 : 0.22;
|
||||
return Math.Round(Math.Min(1.0, baseConfidence + normalized * range), 4);
|
||||
}
|
||||
|
||||
private static bool IsPointInsideBox(Point3d p, Extents3d box, double tol)
|
||||
{
|
||||
return p.X >= box.MinPoint.X - tol && p.X <= box.MaxPoint.X + tol
|
||||
&& p.Y >= box.MinPoint.Y - tol && p.Y <= box.MaxPoint.Y + tol
|
||||
&& p.Z >= box.MinPoint.Z - tol && p.Z <= box.MaxPoint.Z + tol;
|
||||
}
|
||||
|
||||
private static bool IsPointOnPanelFace(Point3d p, Extents3d box, Vector3d panelNormal, double faceTol, double rangeTol)
|
||||
{
|
||||
// 由于 PanelAnalyzer 的 Normal 是轴对齐向量(1/0),这里直接按主轴判断
|
||||
var ax = Math.Abs(panelNormal.X);
|
||||
var ay = Math.Abs(panelNormal.Y);
|
||||
var az = Math.Abs(panelNormal.Z);
|
||||
|
||||
if (ax >= ay && ax >= az)
|
||||
{
|
||||
if (p.Y < box.MinPoint.Y - rangeTol || p.Y > box.MaxPoint.Y + rangeTol) return false;
|
||||
if (p.Z < box.MinPoint.Z - rangeTol || p.Z > box.MaxPoint.Z + rangeTol) return false;
|
||||
return Math.Abs(p.X - box.MinPoint.X) <= faceTol || Math.Abs(p.X - box.MaxPoint.X) <= faceTol;
|
||||
}
|
||||
|
||||
if (ay >= ax && ay >= az)
|
||||
{
|
||||
if (p.X < box.MinPoint.X - rangeTol || p.X > box.MaxPoint.X + rangeTol) return false;
|
||||
if (p.Z < box.MinPoint.Z - rangeTol || p.Z > box.MaxPoint.Z + rangeTol) return false;
|
||||
return Math.Abs(p.Y - box.MinPoint.Y) <= faceTol || Math.Abs(p.Y - box.MaxPoint.Y) <= faceTol;
|
||||
}
|
||||
|
||||
// Z
|
||||
if (p.X < box.MinPoint.X - rangeTol || p.X > box.MaxPoint.X + rangeTol) return false;
|
||||
if (p.Y < box.MinPoint.Y - rangeTol || p.Y > box.MaxPoint.Y + rangeTol) return false;
|
||||
return Math.Abs(p.Z - box.MinPoint.Z) <= faceTol || Math.Abs(p.Z - box.MaxPoint.Z) <= faceTol;
|
||||
}
|
||||
|
||||
private static double DistancePointToBox(Point3d p, Extents3d box)
|
||||
{
|
||||
double dx = 0, dy = 0, dz = 0;
|
||||
|
||||
if (p.X < box.MinPoint.X) dx = box.MinPoint.X - p.X;
|
||||
else if (p.X > box.MaxPoint.X) dx = p.X - box.MaxPoint.X;
|
||||
|
||||
if (p.Y < box.MinPoint.Y) dy = box.MinPoint.Y - p.Y;
|
||||
else if (p.Y > box.MaxPoint.Y) dy = p.Y - box.MaxPoint.Y;
|
||||
|
||||
if (p.Z < box.MinPoint.Z) dz = box.MinPoint.Z - p.Z;
|
||||
else if (p.Z > box.MaxPoint.Z) dz = p.Z - box.MaxPoint.Z;
|
||||
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
private static List<HoleInfo> MergeNearbyHoles(List<HoleInfo> holes)
|
||||
{
|
||||
if (holes.Count <= 1)
|
||||
return holes;
|
||||
|
||||
const double mergeTolerance = 1.0; // 1mm
|
||||
var merged = new List<HoleInfo>();
|
||||
var used = new bool[holes.Count];
|
||||
|
||||
for (int i = 0; i < holes.Count; i++)
|
||||
{
|
||||
if (used[i]) continue;
|
||||
|
||||
var current = holes[i];
|
||||
used[i] = true;
|
||||
|
||||
for (int j = i + 1; j < holes.Count; j++)
|
||||
{
|
||||
if (used[j]) continue;
|
||||
var other = holes[j];
|
||||
|
||||
if (!string.Equals(current.PanelId, other.PanelId, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var dist = Math.Sqrt(
|
||||
Math.Pow(current.Position[0] - other.Position[0], 2) +
|
||||
Math.Pow(current.Position[1] - other.Position[1], 2) +
|
||||
Math.Pow(current.Position[2] - other.Position[2], 2)
|
||||
);
|
||||
|
||||
var diameterCompatible =
|
||||
current.Diameter <= 0 || other.Diameter <= 0 ||
|
||||
Math.Abs(current.Diameter - other.Diameter) <= 0.5;
|
||||
|
||||
if (dist <= mergeTolerance && diameterCompatible)
|
||||
{
|
||||
used[j] = true;
|
||||
// 保留深度更大的(信息更多)
|
||||
if (other.Depth > current.Depth)
|
||||
current = other;
|
||||
}
|
||||
}
|
||||
|
||||
merged.Add(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private sealed class PanelCandidate
|
||||
{
|
||||
public PanelInfo Panel { get; }
|
||||
public Extents3d Extents { get; }
|
||||
public Vector3d Normal { get; }
|
||||
|
||||
public PanelCandidate(PanelInfo panel)
|
||||
{
|
||||
Panel = panel;
|
||||
Extents = ToExtents(panel.BoundingBox!);
|
||||
Normal = ToVector(panel.Normal);
|
||||
if (Normal.Length > 1e-9) Normal = Normal.GetNormal();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PanelBindingResult
|
||||
{
|
||||
public PanelCandidate PanelCandidate { get; }
|
||||
public string BindingSource { get; }
|
||||
public double Confidence { get; }
|
||||
public string BindingReason { get; }
|
||||
|
||||
public PanelBindingResult(
|
||||
PanelCandidate panelCandidate,
|
||||
string bindingSource,
|
||||
double confidence,
|
||||
string bindingReason)
|
||||
{
|
||||
PanelCandidate = panelCandidate;
|
||||
BindingSource = bindingSource;
|
||||
Confidence = confidence;
|
||||
BindingReason = bindingReason;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockCircleMarker
|
||||
{
|
||||
public Point3d Center { get; }
|
||||
public Vector3d Normal { get; }
|
||||
public double Radius { get; }
|
||||
|
||||
public BlockCircleMarker(Point3d center, Vector3d normal, double radius)
|
||||
{
|
||||
Center = center;
|
||||
Normal = normal;
|
||||
Radius = radius;
|
||||
}
|
||||
}
|
||||
|
||||
private static Extents3d ToExtents(BoundingBox3D box)
|
||||
{
|
||||
var min = new Point3d(box.Min[0], box.Min[1], box.Min[2]);
|
||||
var max = new Point3d(box.Max[0], box.Max[1], box.Max[2]);
|
||||
return new Extents3d(min, max);
|
||||
}
|
||||
|
||||
private static Vector3d ToVector(double[] arr)
|
||||
{
|
||||
if (arr.Length < 3) return new Vector3d(0, 0, 1);
|
||||
return new Vector3d(arr[0], arr[1], arr[2]);
|
||||
}
|
||||
}
|
||||
@ -1,966 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 导出数据(JSON 根对象)
|
||||
/// </summary>
|
||||
public class ExportData
|
||||
{
|
||||
/// <summary>
|
||||
/// 数据格式版本
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "2.6";
|
||||
|
||||
/// <summary>
|
||||
/// 导出时间 (ISO 8601)
|
||||
/// </summary>
|
||||
public string ExportedAt { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 源文件名
|
||||
/// </summary>
|
||||
public string SourceFile { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 导出软件
|
||||
/// </summary>
|
||||
public string Software { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 插件版本
|
||||
/// </summary>
|
||||
public string PluginVersion { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 板材模型文件名
|
||||
/// </summary>
|
||||
public string ModelFile { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 板材数量
|
||||
/// </summary>
|
||||
public int PanelCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图纸单位(毫米/厘米/米等)
|
||||
/// </summary>
|
||||
public string Units { get; set; } = "毫米";
|
||||
|
||||
/// <summary>
|
||||
/// CAD 世界坐标系标识。
|
||||
/// 当前导出统一按 AutoCAD 世界坐标:X 向右,Y 向前,Z 向上。
|
||||
/// Web 端后续做孔位吸附时,不再需要靠启发式猜测坐标系。
|
||||
/// </summary>
|
||||
public string CoordinateSystem { get; set; } = "AUTOCAD_WORLD_Z_UP";
|
||||
|
||||
/// <summary>
|
||||
/// 原点偏移 [X, Y, Z] (mm)
|
||||
/// 所有坐标已应用此偏移,使模型居中于原点
|
||||
/// </summary>
|
||||
public double[]? OriginOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模型包围盒 [minX, minY, minZ, maxX, maxY, maxZ] (mm)
|
||||
/// 坐标已应用 OriginOffset,与导出的 OBJ 坐标系保持一致。
|
||||
/// </summary>
|
||||
public double[]? ModelBounds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导出模式:
|
||||
/// - product_bundle:旧整套家具兼容导出
|
||||
/// - single_asset:资产中心单资产导出
|
||||
/// </summary>
|
||||
public string? ExportMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型提示(仅 single_asset 模式使用):
|
||||
/// BOARD / COMPONENT / HARDWARE
|
||||
/// </summary>
|
||||
public string? AssetTypeHint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次导出的源实体数量(便于排查“选少了/选多了”)
|
||||
/// </summary>
|
||||
public int? SourceEntityCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 板材列表(v2.0 新增)
|
||||
/// </summary>
|
||||
public List<PanelInfo> Panels { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 孔位列表(v2.0 新增)
|
||||
/// </summary>
|
||||
public List<HoleInfo> Holes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 被过滤/排除的孔位列表(兜底用)。
|
||||
///
|
||||
/// 说明:
|
||||
/// - Holes 是“默认展示/默认参与关联”的孔位;
|
||||
/// - ExcludedHoles 用于排查/兜底模式:当过滤规则误判导致漏孔时,Web 端可让设计师手动复核并启用。
|
||||
/// - Reason 必须为中文,便于非技术用户理解。
|
||||
/// </summary>
|
||||
public List<ExcludedHoleInfo>? ExcludedHoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位分组(v2.2.4 新增)
|
||||
/// 典型用法:偏心件孔(大孔+侧孔)在几何上是两个圆柱孔,但业务上希望计为 1 个“孔位”。
|
||||
/// </summary>
|
||||
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 安装图样(v2.5 兼容字段)。
|
||||
/// 新方案主字段是 SnapPatterns;这里继续保留给旧 Web 端兼容。
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> MountPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 吸附图样(v2.6 新主字段)。
|
||||
/// 内容与 MountPatterns 保持一致,Web 端后续优先读取 snapPatterns。
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> SnapPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 吸附面(v2.6 新增)。
|
||||
/// 每块板件至少提供 6 个轴向外表面,用于辅助装配阶段做面级吸附候选。
|
||||
/// </summary>
|
||||
public List<SnapSurfaceInfo> SnapSurfaces { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位数量(v2.2.4 新增)
|
||||
/// 计算方式:以 HoleGroupId 合并计数(HoleGroupId 为空则按 Hole.Id 计数)。
|
||||
/// </summary>
|
||||
public int? LogicalHoleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 边缘特征列表(倒角/圆角)(v2.1 新增)
|
||||
/// </summary>
|
||||
public List<EdgeFeatureInfo> EdgeFeatures { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 五金件类型列表
|
||||
/// </summary>
|
||||
public List<HardwareType> HardwareTypes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 图层信息映射(v2.2 新增)
|
||||
/// key: 图层名称, value: 图层分类信息
|
||||
/// </summary>
|
||||
public Dictionary<string, LayerInfo> Layers { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 五金件按图层分类汇总(v2.2 新增)
|
||||
/// </summary>
|
||||
public List<HardwareLayerSummary> HardwareSummary { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 装配语义导出层(v2.4 草稿)
|
||||
/// 说明:
|
||||
/// - 仅导出“板件如何与其他板件/五金发生装配关系”的语义线索;
|
||||
/// - 不包含最终安装顺序,安装顺序仍由 Web 端人工设定。
|
||||
/// </summary>
|
||||
public AssemblyData? Assembly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 装配语义导出层
|
||||
/// </summary>
|
||||
public class AssemblyData
|
||||
{
|
||||
/// <summary>
|
||||
/// 装配语义格式版本
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "1.0";
|
||||
|
||||
/// <summary>
|
||||
/// 每块板件的装配语义
|
||||
/// </summary>
|
||||
public List<PanelAssemblyInfo> PanelAssemblies { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 板件之间的连接关系
|
||||
/// </summary>
|
||||
public List<AssemblyConnectionInfo> Connections { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单块板件的装配语义
|
||||
/// </summary>
|
||||
public class PanelAssemblyInfo
|
||||
{
|
||||
public string PanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 装配模式:slide_in / drop_in / rotate_into_place / unknown
|
||||
/// </summary>
|
||||
public string MountMode { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 主装配轴(单位向量,表示从最终位往外拉开的方向)
|
||||
/// </summary>
|
||||
public double[] AssemblyAxis { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 离散装配方向:left / right / front / back / top / bottom / unknown
|
||||
/// </summary>
|
||||
public string AssemblyDirection { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 进入装配前建议预偏移向量(mm)
|
||||
/// </summary>
|
||||
public double[]? PreAssembleOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 建议装配移动距离(mm)
|
||||
/// </summary>
|
||||
public double TravelDistance { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 依赖的支撑/目标板件 ID
|
||||
/// </summary>
|
||||
public List<string> DependsOn { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 主要支撑板件 ID
|
||||
/// </summary>
|
||||
public string? PrimarySupportPanelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标板件 ID 列表
|
||||
/// </summary>
|
||||
public List<string> TargetPanelIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 板件级配合面映射(left/right/front/back/top/bottom)
|
||||
/// </summary>
|
||||
public PanelAssemblyMateMap Mates { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 语义置信度(0-1)
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 风险/歧义提示
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 是否为自动推导结果
|
||||
/// </summary>
|
||||
public bool AutoGenerated { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 板件之间的连接关系
|
||||
/// </summary>
|
||||
public class AssemblyConnectionInfo
|
||||
{
|
||||
public string SourcePanelId { get; set; } = "";
|
||||
public string TargetPanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 连接类型:hole_group_hardware / hole_hardware / panel_hardware / adjacent_face / panel_type_fallback
|
||||
/// </summary>
|
||||
public string ConnectionType { get; set; } = "adjacent_face";
|
||||
|
||||
public string SelfFace { get; set; } = "unknown";
|
||||
public string TargetFace { get; set; } = "unknown";
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 板件级配合面映射
|
||||
/// </summary>
|
||||
public class PanelAssemblyMateMap
|
||||
{
|
||||
public List<string>? Left { get; set; }
|
||||
public List<string>? Right { get; set; }
|
||||
public List<string>? Front { get; set; }
|
||||
public List<string>? Back { get; set; }
|
||||
public List<string>? Top { get; set; }
|
||||
public List<string>? Bottom { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 板材信息(v2.0 新增)
|
||||
/// </summary>
|
||||
public class PanelInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 板材 ID(自动生成,如 panel_001)
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 源实体句柄
|
||||
/// </summary>
|
||||
public string SourceHandle { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 源图层名
|
||||
/// </summary>
|
||||
public string? SourceLayer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 推断的板件类型(v2.2 新增)
|
||||
/// </summary>
|
||||
public string? PanelType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 中心位置 [X, Y, Z] (mm)
|
||||
/// </summary>
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 尺寸 [长, 宽, 厚] (mm),已排序
|
||||
/// </summary>
|
||||
public double[] Size { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 板材法向量(厚度方向)[X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 边界框
|
||||
/// </summary>
|
||||
public BoundingBox3D? BoundingBox { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该板材上的孔位列表
|
||||
/// </summary>
|
||||
public List<HoleInfo> Holes { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件上的逻辑孔位分组(v2.2.4 新增)
|
||||
/// </summary>
|
||||
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件上的安装图样(v2.5 兼容字段)
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> MountPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件上的吸附图样(v2.6 新主字段)
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> SnapPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件的吸附面(v2.6 新增)
|
||||
/// </summary>
|
||||
public List<SnapSurfaceInfo> SnapSurfaces { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 被排除的孔位(用于兜底/排查模式)
|
||||
/// </summary>
|
||||
public class ExcludedHoleInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 孔位数据(与 Holes 相同结构)
|
||||
/// </summary>
|
||||
public HoleInfo Hole { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 排除原因(中文)
|
||||
/// </summary>
|
||||
public string Reason { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 孔位信息(v2.0 新增)
|
||||
/// </summary>
|
||||
public class HoleInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 孔位 ID(如 panel_001_hole_001)
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 孔中心位置 [X, Y, Z] (mm)
|
||||
/// </summary>
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 孔方向(法向量)[X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 孔直径 (mm)
|
||||
/// </summary>
|
||||
public double Diameter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 孔半径 (mm)
|
||||
/// </summary>
|
||||
public double Radius { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 孔深度 (mm)
|
||||
/// </summary>
|
||||
public double Depth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属板材 ID
|
||||
/// </summary>
|
||||
public string PanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 孔位检测来源:
|
||||
/// - brep:来自板件实体 Brep 真孔
|
||||
/// - marker:来自图纸标注/块/点位兜底识别
|
||||
/// </summary>
|
||||
public string DetectionSource { get; set; } = "brep";
|
||||
|
||||
/// <summary>
|
||||
/// 孔位与板件的绑定来源:
|
||||
/// - panel_brep:在当前板件实体内直接提取
|
||||
/// - marker_face:标注孔贴面匹配到板件
|
||||
/// - marker_box:标注孔按包围盒退化匹配到板件
|
||||
/// </summary>
|
||||
public string BindingSource { get; set; } = "panel_brep";
|
||||
|
||||
/// <summary>
|
||||
/// 孔位与板件绑定的置信度(0-1)
|
||||
/// </summary>
|
||||
public double BindingConfidence { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// 孔位整体可用置信度(0-1)
|
||||
/// 说明:
|
||||
/// - BindingConfidence 只表示“孔位和板件绑定得稳不稳”;
|
||||
/// - Confidence 表示“这个孔位几何与语义整体是否可信”。
|
||||
/// </summary>
|
||||
public double Confidence { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// 绑定原因说明(调试/回放)
|
||||
/// </summary>
|
||||
public string? BindingReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 孔类型:"through"(通孔)| "blind"(盲孔)
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "blind";
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位分组 ID(v2.2.4 新增)
|
||||
/// 偏心件等复合孔会被分到同一个 HoleGroupId,便于“按孔位”计数与展示。
|
||||
/// </summary>
|
||||
public string? HoleGroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位分组类型(v2.2.4 新增)
|
||||
/// 例如:eccentric_screw
|
||||
/// </summary>
|
||||
public string? HoleGroupType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前孔在分组中的角色(v2.2.4 新增)
|
||||
/// 例如:main(主孔)/ side(侧孔)
|
||||
/// </summary>
|
||||
public string? HoleGroupRole { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前孔实际打在板件外表面的面标签。
|
||||
/// 盲孔通常只有 1 个面;通孔通常会有 2 个相对面。
|
||||
/// 例如:top / bottom / left / right / front / back
|
||||
/// </summary>
|
||||
public string[] SurfaceFaces { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 推断的用途(根据直径)
|
||||
/// </summary>
|
||||
public string? InferredUsage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 低置信度或歧义提示
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 针对该孔位的紧固件建议(v2.3 新增)
|
||||
/// 说明:
|
||||
/// - 这是“插件端基于几何特征的候选建议”,不是最终装配 BOM。
|
||||
/// - 常见场景:通过孔径/深度/通孔或盲孔特征,把孔映射到更可能的螺丝直径(例如 4.8mm 的盲孔导孔通常更像 M6)。
|
||||
/// - Web 端可以直接展示 Top1/Top2,并在低置信度时引导人工复核(不要求设计师额外操作)。
|
||||
/// </summary>
|
||||
public List<FastenerSuggestion>? SuggestedFasteners { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 紧固件建议(v2.3 新增)
|
||||
/// </summary>
|
||||
public class FastenerSuggestion
|
||||
{
|
||||
/// <summary>
|
||||
/// 建议类别:screw(螺丝)/ connector(连接件)等
|
||||
/// 当前版本主要用于 screw
|
||||
/// </summary>
|
||||
public string Category { get; set; } = "screw";
|
||||
|
||||
/// <summary>
|
||||
/// 公称直径(mm),例如 6 表示 M6
|
||||
/// </summary>
|
||||
public double NominalDiameter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 常见规格示例(来自五金标准库的“常用项”,用于 UI 展示/人工复核)
|
||||
/// 注意:孔的几何通常无法唯一推断螺丝长度,所以这里给的是候选样例。
|
||||
/// </summary>
|
||||
public List<string>? CommonSpecs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 置信度(0-1),用于排序与低置信度提示
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可读原因(给调试/回放用)
|
||||
/// </summary>
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位分组(v2.2.4 新增)
|
||||
/// </summary>
|
||||
public class HoleGroupInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 分组 ID(如 panel_001_group_001)
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 所属板件 ID
|
||||
/// </summary>
|
||||
public string PanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 分组类型:eccentric_screw / unknown 等
|
||||
/// </summary>
|
||||
public string GroupType { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 该逻辑孔位包含的几何孔 ID 列表
|
||||
/// </summary>
|
||||
public string[] HoleIds { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位参考点(默认取主孔中心)[X, Y, Z] (mm)
|
||||
/// </summary>
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位参考方向(默认取主孔法向)[X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 置信度(0-1),用于调试与后续 Web 端提示
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可读原因(调试/回放)
|
||||
/// </summary>
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前孔组默认作用的安装面。
|
||||
/// 例如:top / bottom / left / right / front / back / unknown
|
||||
/// </summary>
|
||||
public string MountFace { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位实际落在哪些板件外表面上。
|
||||
/// 默认按主孔 + 组内成员孔合并去重。
|
||||
/// </summary>
|
||||
public string[] SurfaceFaces { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 低置信度或歧义提示
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安装图样(v2.5 新增)
|
||||
/// </summary>
|
||||
public class MountPatternInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 图样 ID(如 panel_001_mount_top_001)
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 所属板件 ID
|
||||
/// </summary>
|
||||
public string PanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 默认安装面:top / bottom / left / right / front / back / unknown
|
||||
/// </summary>
|
||||
public string MountFace { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 图样类型:single / line_2 / triangle_3 / rectangle_4 / generic
|
||||
/// </summary>
|
||||
public string PatternType { get; set; } = "generic";
|
||||
|
||||
/// <summary>
|
||||
/// 属于该图样的几何孔位 ID
|
||||
/// </summary>
|
||||
public string[] HoleIds { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 必需命中的孔位 ID
|
||||
/// </summary>
|
||||
public string[] RequiredHoleIds { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 可选命中的孔位 ID
|
||||
/// </summary>
|
||||
public string[] OptionalHoleIds { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 吸附成立所需的最少命中孔数
|
||||
/// </summary>
|
||||
public int MinRequiredHoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 推荐命中孔数(越接近越稳)
|
||||
/// </summary>
|
||||
public int PreferredHoleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许对称复用
|
||||
/// </summary>
|
||||
public bool Symmetry { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图样参考点(默认取图样中心)
|
||||
/// </summary>
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 图样参考法向(默认取安装面法向)
|
||||
/// </summary>
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 图样置信度(0-1)
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 低置信度或歧义提示
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 吸附面信息(v2.6 新增)
|
||||
/// </summary>
|
||||
public class SnapSurfaceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 吸附面 ID(如 panel_001_snap_top)
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 面标签:left / right / front / back / bottom / top
|
||||
/// </summary>
|
||||
public string Face { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 所属板件 ID
|
||||
/// </summary>
|
||||
public string PanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 外法向量 [X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 面中心 [X, Y, Z] (mm)
|
||||
/// </summary>
|
||||
public double[] Center { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 面边界框。薄轴最小值和最大值相同,表示该外表面所在平面。
|
||||
/// </summary>
|
||||
public BoundingBox3D Bounds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 推导置信度(0-1)
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3D 边界框
|
||||
/// </summary>
|
||||
public class BoundingBox3D
|
||||
{
|
||||
/// <summary>
|
||||
/// 最小点 [X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Min { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 最大点 [X, Y, Z]
|
||||
/// </summary>
|
||||
public double[] Max { get; set; } = new double[3];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 五金件类型(按几何特征分组)
|
||||
/// </summary>
|
||||
public class HardwareType
|
||||
{
|
||||
/// <summary>
|
||||
/// 类型 ID (导出时生成,如 type_001)
|
||||
/// </summary>
|
||||
public string TypeId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 几何特征(用于匹配五金件库)
|
||||
/// </summary>
|
||||
public GeometrySignature Signature { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 此类型的所有实例(安装位置)
|
||||
/// </summary>
|
||||
public List<HardwareInstance> Instances { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 匹配到的五金件库 ID(服务端填充)
|
||||
/// </summary>
|
||||
public string? MatchedHardwareId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主要来源图层名(v2.2 新增)
|
||||
/// </summary>
|
||||
public string? PrimaryLayerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 根据图层名推断的五金件类型(v2.2 新增)
|
||||
/// </summary>
|
||||
public string? InferredHardwareType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 几何特征签名(用于五金件匹配)
|
||||
/// </summary>
|
||||
public class GeometrySignature
|
||||
{
|
||||
/// <summary>
|
||||
/// 边界框宽度 (mm) - 最大尺寸
|
||||
/// </summary>
|
||||
public double Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 边界框高度 (mm) - 中间尺寸
|
||||
/// </summary>
|
||||
public double Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 边界框深度 (mm) - 最小尺寸
|
||||
/// </summary>
|
||||
public double Depth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 体积 (mm³),0 表示未知
|
||||
/// </summary>
|
||||
public double Volume { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图块名(如果是图块引用)
|
||||
/// </summary>
|
||||
public string? BlockName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 五金件实例(单个安装位置)
|
||||
/// </summary>
|
||||
public class HardwareInstance
|
||||
{
|
||||
/// <summary>
|
||||
/// 安装位置 (mm)
|
||||
/// </summary>
|
||||
public Position3D Position { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 旋转角度 (弧度)
|
||||
/// </summary>
|
||||
public Rotation3D Rotation { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 缩放比例
|
||||
/// </summary>
|
||||
public Scale3D? Scale { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 源图层名
|
||||
/// </summary>
|
||||
public string? SourceLayer { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 源实体句柄
|
||||
/// </summary>
|
||||
public string? SourceHandle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 源图块名(如果是图块)
|
||||
/// </summary>
|
||||
public string? SourceBlockName { get; set; }
|
||||
|
||||
// ========== v2.0 新增:关联信息 ==========
|
||||
|
||||
/// <summary>
|
||||
/// 最近的孔位 ID(CAD 层面的初步判断)
|
||||
/// </summary>
|
||||
public string? NearestHoleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主绑定板件 ID(优先由孔位/孔组反推)
|
||||
/// </summary>
|
||||
public string? PrimaryPanelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 次绑定板件 ID(常见于连接两块板件的五金)
|
||||
/// </summary>
|
||||
public string? SecondaryPanelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主绑定逻辑孔组 ID(如偏心件孔组、合页孔组)
|
||||
/// </summary>
|
||||
public string? BoundHoleGroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前五金件绑定到的孔位 ID 列表
|
||||
/// </summary>
|
||||
public string[]? BoundHoleIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 绑定模式:
|
||||
/// - hole_group:按逻辑孔组绑定
|
||||
/// - hole:按单孔绑定
|
||||
/// - panel_bbox:按板件包围盒绑定
|
||||
/// - panel_center:按板件中心邻近绑定
|
||||
/// - none:未绑定
|
||||
/// </summary>
|
||||
public string? BindingMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 五金件绑定置信度(0-1)
|
||||
/// </summary>
|
||||
public double? BindingConfidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 绑定原因说明(调试/回放)
|
||||
/// </summary>
|
||||
public string? BindingReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 附近的板材 ID 列表
|
||||
/// </summary>
|
||||
public string[]? NearbyPanelIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 与最近孔位的距离 (mm)
|
||||
/// </summary>
|
||||
public double? DistanceToNearestHole { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3D 位置
|
||||
/// </summary>
|
||||
public class Position3D
|
||||
{
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double Z { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3D 旋转
|
||||
/// </summary>
|
||||
public class Rotation3D
|
||||
{
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
public double Z { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3D 缩放
|
||||
/// </summary>
|
||||
public class Scale3D
|
||||
{
|
||||
public double X { get; set; } = 1;
|
||||
public double Y { get; set; } = 1;
|
||||
public double Z { get; set; } = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图层信息(v2.2 新增)
|
||||
/// </summary>
|
||||
public class LayerInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 图层名称
|
||||
/// </summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 图层分类:panel(板件)| hardware(五金件)| other(其他)
|
||||
/// </summary>
|
||||
public string Category { get; set; } = "other";
|
||||
|
||||
/// <summary>
|
||||
/// 推断的五金件类型(如:hinge, screw, connector 等)
|
||||
/// </summary>
|
||||
public string? HardwareType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该图层上的实体数量
|
||||
/// </summary>
|
||||
public int EntityCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 五金件按图层汇总(v2.2 新增)
|
||||
/// </summary>
|
||||
public class HardwareLayerSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// 图层名称
|
||||
/// </summary>
|
||||
public string LayerName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 推断的五金件类型
|
||||
/// </summary>
|
||||
public string HardwareType { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 数量
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该类型的所有实例 ID
|
||||
/// </summary>
|
||||
public List<string> InstanceIds { get; set; } = new();
|
||||
}
|
||||
@ -1,445 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 安装图样识别器。
|
||||
///
|
||||
/// 设计口径:
|
||||
/// - 吸附面(mount face)先缩小候选范围;
|
||||
/// - HoleGroup 负责把几何孔组织成业务孔位;
|
||||
/// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。
|
||||
/// </summary>
|
||||
public static class MountPatternAnalyzer
|
||||
{
|
||||
private const double DefaultClusterDistanceMm = 220.0;
|
||||
|
||||
public static List<MountPatternInfo> BuildMountPatterns(
|
||||
IReadOnlyList<PanelInfo> panels,
|
||||
IReadOnlyList<HoleInfo> holes,
|
||||
IReadOnlyList<HoleGroupInfo> holeGroups)
|
||||
{
|
||||
var patterns = new List<MountPatternInfo>();
|
||||
if (panels == null || panels.Count == 0)
|
||||
{
|
||||
return patterns;
|
||||
}
|
||||
|
||||
var holesByPanel = holes
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h.PanelId))
|
||||
.GroupBy(h => h.PanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var groupsByPanel = holeGroups
|
||||
.Where(group => !string.IsNullOrWhiteSpace(group.PanelId))
|
||||
.GroupBy(group => group.PanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
var panelHoles = holesByPanel.TryGetValue(panel.Id, out var panelHoleList)
|
||||
? panelHoleList
|
||||
: new List<HoleInfo>();
|
||||
var panelGroups = groupsByPanel.TryGetValue(panel.Id, out var panelGroupList)
|
||||
? panelGroupList
|
||||
: new List<HoleGroupInfo>();
|
||||
|
||||
var units = BuildUnits(panel.Id, panelHoles, panelGroups);
|
||||
if (units.Count == 0)
|
||||
{
|
||||
panel.MountPatterns = new List<MountPatternInfo>();
|
||||
panel.SnapPatterns = new List<MountPatternInfo>();
|
||||
continue;
|
||||
}
|
||||
|
||||
var panelPatterns = new List<MountPatternInfo>();
|
||||
var unitsByFace = units
|
||||
.SelectMany(unit =>
|
||||
unit.MountFaces.Select(face => new { Face = face, Unit = unit }))
|
||||
.GroupBy(item => item.Face, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var faceGroup in unitsByFace)
|
||||
{
|
||||
var clusters = BuildClusters(faceGroup.Select(item => item.Unit).Distinct().ToList());
|
||||
var patternIndex = 0;
|
||||
foreach (var cluster in clusters)
|
||||
{
|
||||
var pattern = BuildPattern(panel.Id, faceGroup.Key, patternIndex++, cluster);
|
||||
panelPatterns.Add(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
panel.MountPatterns = panelPatterns;
|
||||
panel.SnapPatterns = panelPatterns;
|
||||
patterns.AddRange(panelPatterns);
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
public static void AttachPatternsToPanels(IReadOnlyList<PanelInfo> panels, IReadOnlyList<MountPatternInfo> patterns)
|
||||
{
|
||||
var map = patterns
|
||||
.GroupBy(pattern => pattern.PanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
var panelPatterns = map.TryGetValue(panel.Id, out var items)
|
||||
? items
|
||||
: new List<MountPatternInfo>();
|
||||
panel.MountPatterns = panelPatterns;
|
||||
panel.SnapPatterns = panelPatterns;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<MountPatternUnit> BuildUnits(
|
||||
string panelId,
|
||||
IReadOnlyList<HoleInfo> holes,
|
||||
IReadOnlyList<HoleGroupInfo> holeGroups)
|
||||
{
|
||||
var units = new List<MountPatternUnit>();
|
||||
var groupedHoleIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in holeGroups)
|
||||
{
|
||||
foreach (var holeId in group.HoleIds ?? Array.Empty<string>())
|
||||
{
|
||||
groupedHoleIds.Add(holeId);
|
||||
}
|
||||
|
||||
var mountFaces = NormalizeMountFaces(group.SurfaceFaces, group.MountFace);
|
||||
group.MountFace = mountFaces.FirstOrDefault() ?? "unknown";
|
||||
units.Add(new MountPatternUnit
|
||||
{
|
||||
Id = group.Id,
|
||||
PanelId = panelId,
|
||||
HoleIds = group.HoleIds ?? Array.Empty<string>(),
|
||||
Position = group.Position,
|
||||
Normal = group.Normal,
|
||||
GroupType = group.GroupType,
|
||||
MountFaces = mountFaces,
|
||||
Confidence = Clamp01(group.Confidence <= 0 ? 0.72 : group.Confidence),
|
||||
Warnings = group.Warnings?.ToList() ?? new List<string>(),
|
||||
Source = "hole-group",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var hole in holes)
|
||||
{
|
||||
if (groupedHoleIds.Contains(hole.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
units.Add(new MountPatternUnit
|
||||
{
|
||||
Id = hole.Id,
|
||||
PanelId = panelId,
|
||||
HoleIds = new[] { hole.Id },
|
||||
Position = hole.Position,
|
||||
Normal = hole.Normal,
|
||||
GroupType = hole.HoleGroupType,
|
||||
MountFaces = NormalizeMountFaces(hole.SurfaceFaces),
|
||||
Confidence = Clamp01(hole.Confidence <= 0 ? 0.76 : hole.Confidence),
|
||||
Warnings = hole.Warnings?.ToList() ?? new List<string>(),
|
||||
Source = "single-hole",
|
||||
});
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
|
||||
private static List<List<MountPatternUnit>> BuildClusters(IReadOnlyList<MountPatternUnit> units)
|
||||
{
|
||||
var clusters = new List<List<MountPatternUnit>>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (!visited.Add(unit.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cluster = new List<MountPatternUnit>();
|
||||
var queue = new Queue<MountPatternUnit>();
|
||||
queue.Enqueue(unit);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
cluster.Add(current);
|
||||
|
||||
foreach (var candidate in units)
|
||||
{
|
||||
if (visited.Contains(candidate.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Distance(current.Position, candidate.Position) > DefaultClusterDistanceMm)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.Add(candidate.Id);
|
||||
queue.Enqueue(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
clusters.Add(cluster.OrderBy(item => item.Position[0]).ThenBy(item => item.Position[1]).ThenBy(item => item.Position[2]).ToList());
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
private static MountPatternInfo BuildPattern(
|
||||
string panelId,
|
||||
string mountFace,
|
||||
int patternIndex,
|
||||
IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
var holeIds = cluster
|
||||
.SelectMany(unit => unit.HoleIds)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var warnings = cluster
|
||||
.SelectMany(unit => unit.Warnings)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var pointCount = cluster.Count;
|
||||
var patternType = ResolvePatternType(cluster);
|
||||
var isSymmetricRectangle = string.Equals(patternType, "rectangle_4", StringComparison.OrdinalIgnoreCase);
|
||||
var minRequiredHoles = ResolveMinRequiredHoleCount(patternType, pointCount, isSymmetricRectangle);
|
||||
var preferredHoleCount = pointCount;
|
||||
|
||||
var requiredHoleIds = Array.Empty<string>();
|
||||
var optionalHoleIds = Array.Empty<string>();
|
||||
|
||||
if (pointCount <= 3 || !isSymmetricRectangle)
|
||||
{
|
||||
requiredHoleIds = holeIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
optionalHoleIds = holeIds;
|
||||
warnings.Add("四孔对称图样默认按 2 孔完成初始定位,剩余孔位作为增强固定。");
|
||||
}
|
||||
|
||||
if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add("当前图样由几何聚类自动生成,建议后续在资产录入阶段人工复核。");
|
||||
}
|
||||
|
||||
return new MountPatternInfo
|
||||
{
|
||||
Id = $"{panelId}_mount_{mountFace}_{patternIndex:D3}",
|
||||
PanelId = panelId,
|
||||
MountFace = mountFace,
|
||||
PatternType = patternType,
|
||||
HoleIds = holeIds,
|
||||
RequiredHoleIds = requiredHoleIds,
|
||||
OptionalHoleIds = optionalHoleIds,
|
||||
MinRequiredHoles = minRequiredHoles,
|
||||
PreferredHoleCount = preferredHoleCount,
|
||||
Symmetry = isSymmetricRectangle,
|
||||
Position = AveragePosition(cluster),
|
||||
Normal = ResolveMountFaceNormal(mountFace),
|
||||
Confidence = ComputePatternConfidence(cluster, patternType, warnings.Count),
|
||||
Warnings = warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolvePatternType(IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
return cluster.Count switch
|
||||
{
|
||||
1 => "single",
|
||||
2 => "line_2",
|
||||
3 => "triangle_3",
|
||||
4 when IsRectangle(cluster) => "rectangle_4",
|
||||
_ => "generic",
|
||||
};
|
||||
}
|
||||
|
||||
private static int ResolveMinRequiredHoleCount(string patternType, int pointCount, bool isSymmetricRectangle)
|
||||
{
|
||||
if (string.Equals(patternType, "single", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.Equals(patternType, "line_2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (string.Equals(patternType, "triangle_3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (isSymmetricRectangle)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (pointCount <= 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return pointCount <= 4 ? pointCount : Math.Min(pointCount, 4);
|
||||
}
|
||||
|
||||
private static bool IsRectangle(IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
if (cluster.Count != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var distances = new List<double>();
|
||||
for (var i = 0; i < cluster.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < cluster.Count; j++)
|
||||
{
|
||||
distances.Add(Distance(cluster[i].Position, cluster[j].Position));
|
||||
}
|
||||
}
|
||||
|
||||
distances.Sort();
|
||||
if (distances.Count != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sideA = distances[0];
|
||||
var sideB = distances[2];
|
||||
var diagonalA = distances[4];
|
||||
var diagonalB = distances[5];
|
||||
|
||||
return AreClose(sideA, distances[1], 0.12) &&
|
||||
AreClose(sideB, distances[3], 0.12) &&
|
||||
AreClose(diagonalA, diagonalB, 0.08);
|
||||
}
|
||||
|
||||
private static double[] AveragePosition(IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
if (cluster.Count == 0)
|
||||
{
|
||||
return new[] { 0.0, 0.0, 0.0 };
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
Math.Round(cluster.Average(item => item.Position[0]), 2),
|
||||
Math.Round(cluster.Average(item => item.Position[1]), 2),
|
||||
Math.Round(cluster.Average(item => item.Position[2]), 2),
|
||||
};
|
||||
}
|
||||
|
||||
private static double[] ResolveMountFaceNormal(string mountFace)
|
||||
{
|
||||
return mountFace.ToLowerInvariant() switch
|
||||
{
|
||||
"left" => new[] { -1.0, 0.0, 0.0 },
|
||||
"right" => new[] { 1.0, 0.0, 0.0 },
|
||||
"front" => new[] { 0.0, -1.0, 0.0 },
|
||||
"back" => new[] { 0.0, 1.0, 0.0 },
|
||||
"bottom" => new[] { 0.0, 0.0, -1.0 },
|
||||
"top" => new[] { 0.0, 0.0, 1.0 },
|
||||
_ => new[] { 0.0, 0.0, 1.0 },
|
||||
};
|
||||
}
|
||||
|
||||
private static double ComputePatternConfidence(
|
||||
IReadOnlyList<MountPatternUnit> cluster,
|
||||
string patternType,
|
||||
int warningCount)
|
||||
{
|
||||
if (cluster.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var baseConfidence = cluster.Average(item => item.Confidence);
|
||||
if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
baseConfidence = Math.Min(baseConfidence, 0.68);
|
||||
}
|
||||
|
||||
if (warningCount > 0)
|
||||
{
|
||||
baseConfidence -= Math.Min(0.14, warningCount * 0.04);
|
||||
}
|
||||
|
||||
return Math.Round(Clamp01(baseConfidence), 4);
|
||||
}
|
||||
|
||||
private static string[] NormalizeMountFaces(IEnumerable<string>? faces, string? fallbackFace = null)
|
||||
{
|
||||
var normalized = (faces ?? Array.Empty<string>())
|
||||
.Where(face => !string.IsNullOrWhiteSpace(face))
|
||||
.Select(face => face.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count > 0)
|
||||
{
|
||||
return normalized.ToArray();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackFace))
|
||||
{
|
||||
return new[] { fallbackFace!.Trim().ToLowerInvariant() };
|
||||
}
|
||||
|
||||
return new[] { "unknown" };
|
||||
}
|
||||
|
||||
private static double Distance(IReadOnlyList<double> left, IReadOnlyList<double> right)
|
||||
{
|
||||
var dx = left[0] - right[0];
|
||||
var dy = left[1] - right[1];
|
||||
var dz = left[2] - right[2];
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
private static bool AreClose(double left, double right, double toleranceRatio)
|
||||
{
|
||||
var baseValue = Math.Max(Math.Abs(left), Math.Abs(right));
|
||||
if (baseValue <= 1e-6)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Math.Abs(left - right) / baseValue <= toleranceRatio;
|
||||
}
|
||||
|
||||
private static double Clamp01(double value)
|
||||
{
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
private sealed class MountPatternUnit
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string PanelId { get; set; } = "";
|
||||
public string[] HoleIds { get; set; } = Array.Empty<string>();
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
public string? GroupType { get; set; }
|
||||
public string[] MountFaces { get; set; } = Array.Empty<string>();
|
||||
public double Confidence { get; set; }
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public string Source { get; set; } = "single-hole";
|
||||
}
|
||||
}
|
||||
@ -1,601 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
using Autodesk.AutoCAD.DatabaseServices;
|
||||
using Autodesk.AutoCAD.Geometry;
|
||||
using Autodesk.AutoCAD.BoundaryRepresentation;
|
||||
|
||||
namespace FurnitureExporter
|
||||
{
|
||||
/// <summary>
|
||||
/// OBJ 格式导出器 (v2.0)
|
||||
/// 使用 Brep API 从 AutoCAD Solid3d 提取真实三角网格
|
||||
/// 保留开孔、倒角等几何细节
|
||||
/// </summary>
|
||||
public class ObjExporter
|
||||
{
|
||||
private readonly StringBuilder _vertices = new StringBuilder();
|
||||
private readonly StringBuilder _normals = new StringBuilder();
|
||||
private readonly StringBuilder _faces = new StringBuilder();
|
||||
private readonly Dictionary<string, int> _vertexIndexMap = new Dictionary<string, int>();
|
||||
private readonly Dictionary<string, int> _normalIndexMap = new Dictionary<string, int>();
|
||||
|
||||
private int _vertexCount = 0;
|
||||
private int _normalCount = 0;
|
||||
private int _solidCount = 0;
|
||||
private int _faceCount = 0;
|
||||
private Point3d _originOffset = Point3d.Origin;
|
||||
|
||||
// 网格密度控制参数
|
||||
private double _meshDensityFactor = 45.0; // 默认改为更保守的密度,减少大平面过度细分
|
||||
private int _maxSubdivisions = 20000; // 默认降低最大细分次数,避免简单板件导出异常膨胀
|
||||
|
||||
/// <summary>
|
||||
/// 设置网格密度因子(默认 100,值越大网格越密,文件越大)
|
||||
/// </summary>
|
||||
public void SetMeshDensity(double densityFactor, int maxSubdivisions = 20000)
|
||||
{
|
||||
_meshDensityFactor = Math.Max(10, Math.Min(200, densityFactor));
|
||||
_maxSubdivisions = Math.Max(1000, Math.Min(200000, maxSubdivisions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置原点偏移(将模型移动到世界原点)
|
||||
/// </summary>
|
||||
public void SetOriginOffset(Point3d offset)
|
||||
{
|
||||
_originOffset = offset;
|
||||
}
|
||||
|
||||
private static string BuildVertexKey(Point3d point)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0:F6}|{1:F6}|{2:F6}",
|
||||
point.X,
|
||||
point.Y,
|
||||
point.Z
|
||||
);
|
||||
}
|
||||
|
||||
private static string BuildNormalKey(Vector3d normal)
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0:F6}|{1:F6}|{2:F6}",
|
||||
normal.X,
|
||||
normal.Y,
|
||||
normal.Z
|
||||
);
|
||||
}
|
||||
|
||||
private int GetOrCreateVertexIndex(Point3d point)
|
||||
{
|
||||
var key = BuildVertexKey(point);
|
||||
if (_vertexIndexMap.TryGetValue(key, out var existingIndex))
|
||||
{
|
||||
return existingIndex;
|
||||
}
|
||||
|
||||
_vertices.AppendLine(string.Format(CultureInfo.InvariantCulture,
|
||||
"v {0:F6} {1:F6} {2:F6}", point.X, point.Y, point.Z));
|
||||
_vertexCount++;
|
||||
_vertexIndexMap[key] = _vertexCount;
|
||||
return _vertexCount;
|
||||
}
|
||||
|
||||
private int GetOrCreateNormalIndex(Vector3d normal)
|
||||
{
|
||||
var key = BuildNormalKey(normal);
|
||||
if (_normalIndexMap.TryGetValue(key, out var existingIndex))
|
||||
{
|
||||
return existingIndex;
|
||||
}
|
||||
|
||||
_normals.AppendLine(string.Format(CultureInfo.InvariantCulture,
|
||||
"vn {0:F6} {1:F6} {2:F6}", normal.X, normal.Y, normal.Z));
|
||||
_normalCount++;
|
||||
_normalIndexMap[key] = _normalCount;
|
||||
return _normalCount;
|
||||
}
|
||||
|
||||
private double CalculateMaxNodeSpacing(Extents3d extents)
|
||||
{
|
||||
var size = extents.MinPoint.GetVectorTo(extents.MaxPoint);
|
||||
var longestEdge = Math.Max(size.X, Math.Max(size.Y, size.Z));
|
||||
var spacingByDensity = longestEdge / _meshDensityFactor;
|
||||
|
||||
// 约束取值:
|
||||
// - 过小会导致大平面和倒角被极度细分
|
||||
// - 过大又会明显破坏孔和边缘观感
|
||||
return Math.Max(4.0, Math.Min(18.0, spacingByDensity));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加任意支持的实体到 OBJ 导出器
|
||||
/// </summary>
|
||||
public void AddEntity(Entity entity, string? name = null)
|
||||
{
|
||||
if (entity == null) return;
|
||||
|
||||
if (entity is Solid3d solid)
|
||||
{
|
||||
AddSolid(solid, name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entity is BlockReference blockReference)
|
||||
{
|
||||
AddBlockReference(blockReference, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用 Brep API 添加一个 Solid3d 实体(提取真实几何)
|
||||
/// </summary>
|
||||
public void AddSolid(Solid3d solid, string? name = null)
|
||||
{
|
||||
if (solid == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_solidCount++;
|
||||
var solidName = name ?? $"panel_{_solidCount - 1:D3}";
|
||||
|
||||
var extents = solid.GeometricExtents;
|
||||
|
||||
// 对象名必须与该对象的面一起输出;否则 obj2gltf 会把全部面归到最后一个对象
|
||||
_faces.AppendLine($"o {solidName}");
|
||||
|
||||
// 使用 Brep API 提取真实几何
|
||||
using (var brep = new Brep(solid))
|
||||
{
|
||||
using (var meshControl = new Mesh2dControl())
|
||||
{
|
||||
// 设置网格控制参数
|
||||
// MaxNodeSpacing 控制网格点之间的最大距离
|
||||
meshControl.MaxNodeSpacing = CalculateMaxNodeSpacing(extents);
|
||||
meshControl.MaxSubdivisions = (uint)_maxSubdivisions;
|
||||
|
||||
using (var meshFilter = new Mesh2dFilter())
|
||||
{
|
||||
// 将控制参数应用到 Brep
|
||||
meshFilter.Insert(brep, meshControl);
|
||||
|
||||
using (var mesh = new Mesh2d(meshFilter))
|
||||
{
|
||||
// 遍历网格元素(三角形或四边形)
|
||||
foreach (Element2d element in mesh.Element2ds)
|
||||
{
|
||||
var points = new List<Point3d>();
|
||||
// 提取元素的顶点
|
||||
foreach (Node node in element.Nodes)
|
||||
{
|
||||
// 应用原点偏移
|
||||
var point = new Point3d(
|
||||
node.Point.X + _originOffset.X,
|
||||
node.Point.Y + _originOffset.Y,
|
||||
node.Point.Z + _originOffset.Z
|
||||
);
|
||||
points.Add(point);
|
||||
|
||||
// 必须显式 Dispose 节点,否则会崩溃
|
||||
node.Dispose();
|
||||
}
|
||||
|
||||
// 必须显式 Dispose 元素,否则会崩溃
|
||||
element.Dispose();
|
||||
|
||||
if (points.Count < 3) continue;
|
||||
|
||||
// 计算面法线
|
||||
Vector3d normal = CalculateFaceNormal(points);
|
||||
var normalIndex = GetOrCreateNormalIndex(normal);
|
||||
|
||||
// 写入顶点(复用相同坐标,避免 OBJ 文本重复膨胀)
|
||||
var vertexIndices = new List<int>();
|
||||
foreach (var pt in points)
|
||||
{
|
||||
vertexIndices.Add(GetOrCreateVertexIndex(pt));
|
||||
}
|
||||
|
||||
// 写入面
|
||||
if (points.Count == 3)
|
||||
{
|
||||
// 三角形
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndex} {vertexIndices[1]}//{normalIndex} {vertexIndices[2]}//{normalIndex}");
|
||||
_faceCount++;
|
||||
}
|
||||
else if (points.Count == 4)
|
||||
{
|
||||
// 四边形 - 拆分为两个三角形
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndex} {vertexIndices[1]}//{normalIndex} {vertexIndices[2]}//{normalIndex}");
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndex} {vertexIndices[2]}//{normalIndex} {vertexIndices[3]}//{normalIndex}");
|
||||
_faceCount += 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 多边形 - 扇形三角化
|
||||
for (int i = 1; i < points.Count - 1; i++)
|
||||
{
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndex} {vertexIndices[i]}//{normalIndex} {vertexIndices[i + 1]}//{normalIndex}");
|
||||
_faceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Autodesk.AutoCAD.Runtime.Exception ex)
|
||||
{
|
||||
// Brep API 失败时回退到边界框方法
|
||||
System.Diagnostics.Debug.WriteLine($"Brep 导出失败,回退到边界框: {ex.Message}");
|
||||
AddSolidBoundingBox(solid, name);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"导出 Solid3d 失败: {ex.Message}");
|
||||
// 尝试回退到边界框
|
||||
try
|
||||
{
|
||||
AddSolidBoundingBox(solid, name);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 完全失败,跳过此实体
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试展开图块引用并导出内部几何。
|
||||
/// 如果图块内部没有可导出的 3D 实体,则退化为包围盒模型,至少保证资产中心有模型可用。
|
||||
/// </summary>
|
||||
private void AddBlockReference(BlockReference blockReference, string? name = null)
|
||||
{
|
||||
if (blockReference == null) return;
|
||||
|
||||
var exploded = new DBObjectCollection();
|
||||
try
|
||||
{
|
||||
blockReference.Explode(exploded);
|
||||
|
||||
var baseName = name ?? $"block_{_solidCount:D3}";
|
||||
var childIndex = 0;
|
||||
var exportedAny = false;
|
||||
|
||||
foreach (DBObject dbObject in exploded)
|
||||
{
|
||||
if (dbObject is not Entity entity)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entity is not Solid3d && entity is not BlockReference)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddEntity(entity, $"{baseName}_{childIndex:D3}");
|
||||
childIndex++;
|
||||
exportedAny = true;
|
||||
}
|
||||
|
||||
if (!exportedAny)
|
||||
{
|
||||
AddBlockReferenceBoundingBox(blockReference, baseName);
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"导出 BlockReference 失败,回退到边界框: {ex.Message}");
|
||||
AddBlockReferenceBoundingBox(blockReference, name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (DBObject dbObject in exploded)
|
||||
{
|
||||
dbObject.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 图块引用的包围盒导出(回退方案)
|
||||
/// </summary>
|
||||
private void AddBlockReferenceBoundingBox(BlockReference blockReference, string? name = null)
|
||||
{
|
||||
if (blockReference == null) return;
|
||||
|
||||
_solidCount++;
|
||||
var solidName = name ?? $"block_{_solidCount - 1:D3}";
|
||||
|
||||
var extents = blockReference.GeometricExtents;
|
||||
var min = extents.MinPoint;
|
||||
var max = extents.MaxPoint;
|
||||
|
||||
var minOffset = new Point3d(min.X + _originOffset.X, min.Y + _originOffset.Y, min.Z + _originOffset.Z);
|
||||
var maxOffset = new Point3d(max.X + _originOffset.X, max.Y + _originOffset.Y, max.Z + _originOffset.Z);
|
||||
|
||||
var vertices = new Point3d[]
|
||||
{
|
||||
new Point3d(minOffset.X, minOffset.Y, minOffset.Z),
|
||||
new Point3d(maxOffset.X, minOffset.Y, minOffset.Z),
|
||||
new Point3d(maxOffset.X, maxOffset.Y, minOffset.Z),
|
||||
new Point3d(minOffset.X, maxOffset.Y, minOffset.Z),
|
||||
new Point3d(minOffset.X, minOffset.Y, maxOffset.Z),
|
||||
new Point3d(maxOffset.X, minOffset.Y, maxOffset.Z),
|
||||
new Point3d(maxOffset.X, maxOffset.Y, maxOffset.Z),
|
||||
new Point3d(minOffset.X, maxOffset.Y, maxOffset.Z),
|
||||
};
|
||||
|
||||
_faces.AppendLine($"o {solidName}");
|
||||
|
||||
var vertexIndices = vertices.Select(GetOrCreateVertexIndex).ToArray();
|
||||
var normalIndices = new int[]
|
||||
{
|
||||
GetOrCreateNormalIndex(new Vector3d(0, 0, -1)),
|
||||
GetOrCreateNormalIndex(new Vector3d(0, 0, 1)),
|
||||
GetOrCreateNormalIndex(new Vector3d(0, -1, 0)),
|
||||
GetOrCreateNormalIndex(new Vector3d(0, 1, 0)),
|
||||
GetOrCreateNormalIndex(new Vector3d(-1, 0, 0)),
|
||||
GetOrCreateNormalIndex(new Vector3d(1, 0, 0)),
|
||||
};
|
||||
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[0]} {vertexIndices[2]}//{normalIndices[0]} {vertexIndices[1]}//{normalIndices[0]}");
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[0]} {vertexIndices[3]}//{normalIndices[0]} {vertexIndices[2]}//{normalIndices[0]}");
|
||||
|
||||
_faces.AppendLine($"f {vertexIndices[4]}//{normalIndices[1]} {vertexIndices[5]}//{normalIndices[1]} {vertexIndices[6]}//{normalIndices[1]}");
|
||||
_faces.AppendLine($"f {vertexIndices[4]}//{normalIndices[1]} {vertexIndices[6]}//{normalIndices[1]} {vertexIndices[7]}//{normalIndices[1]}");
|
||||
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[2]} {vertexIndices[1]}//{normalIndices[2]} {vertexIndices[5]}//{normalIndices[2]}");
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[2]} {vertexIndices[5]}//{normalIndices[2]} {vertexIndices[4]}//{normalIndices[2]}");
|
||||
|
||||
_faces.AppendLine($"f {vertexIndices[2]}//{normalIndices[3]} {vertexIndices[3]}//{normalIndices[3]} {vertexIndices[7]}//{normalIndices[3]}");
|
||||
_faces.AppendLine($"f {vertexIndices[2]}//{normalIndices[3]} {vertexIndices[7]}//{normalIndices[3]} {vertexIndices[6]}//{normalIndices[3]}");
|
||||
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[4]} {vertexIndices[4]}//{normalIndices[4]} {vertexIndices[7]}//{normalIndices[4]}");
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[4]} {vertexIndices[7]}//{normalIndices[4]} {vertexIndices[3]}//{normalIndices[4]}");
|
||||
|
||||
_faces.AppendLine($"f {vertexIndices[1]}//{normalIndices[5]} {vertexIndices[2]}//{normalIndices[5]} {vertexIndices[6]}//{normalIndices[5]}");
|
||||
_faces.AppendLine($"f {vertexIndices[1]}//{normalIndices[5]} {vertexIndices[6]}//{normalIndices[5]} {vertexIndices[5]}//{normalIndices[5]}");
|
||||
|
||||
_faceCount += 12;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算面法线(使用 Newell 方法,适用于任意多边形)
|
||||
/// </summary>
|
||||
private Vector3d CalculateFaceNormal(List<Point3d> points)
|
||||
{
|
||||
double nx = 0, ny = 0, nz = 0;
|
||||
int n = points.Count;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var current = points[i];
|
||||
var next = points[(i + 1) % n];
|
||||
|
||||
nx += (current.Y - next.Y) * (current.Z + next.Z);
|
||||
ny += (current.Z - next.Z) * (current.X + next.X);
|
||||
nz += (current.X - next.X) * (current.Y + next.Y);
|
||||
}
|
||||
|
||||
var normal = new Vector3d(nx, ny, nz);
|
||||
|
||||
// 归一化
|
||||
double length = normal.Length;
|
||||
if (length > 1e-10)
|
||||
{
|
||||
normal = normal.DivideBy(length);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果法线接近零,使用默认值
|
||||
normal = new Vector3d(0, 0, 1);
|
||||
}
|
||||
|
||||
return normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用边界框方法添加实体(作为回退方案)
|
||||
/// </summary>
|
||||
private void AddSolidBoundingBox(Solid3d solid, string? name = null)
|
||||
{
|
||||
if (solid == null) return;
|
||||
|
||||
_solidCount++;
|
||||
var solidName = name ?? $"panel_{_solidCount - 1:D3}";
|
||||
|
||||
// 获取实体的边界框
|
||||
var extents = solid.GeometricExtents;
|
||||
var min = extents.MinPoint;
|
||||
var max = extents.MaxPoint;
|
||||
|
||||
// 应用原点偏移
|
||||
var minOffset = new Point3d(min.X + _originOffset.X, min.Y + _originOffset.Y, min.Z + _originOffset.Z);
|
||||
var maxOffset = new Point3d(max.X + _originOffset.X, max.Y + _originOffset.Y, max.Z + _originOffset.Z);
|
||||
|
||||
// 生成长方体的 8 个顶点
|
||||
var vertices = new Point3d[]
|
||||
{
|
||||
new Point3d(minOffset.X, minOffset.Y, minOffset.Z), // 0: 前左下
|
||||
new Point3d(maxOffset.X, minOffset.Y, minOffset.Z), // 1: 前右下
|
||||
new Point3d(maxOffset.X, maxOffset.Y, minOffset.Z), // 2: 后右下
|
||||
new Point3d(minOffset.X, maxOffset.Y, minOffset.Z), // 3: 后左下
|
||||
new Point3d(minOffset.X, minOffset.Y, maxOffset.Z), // 4: 前左上
|
||||
new Point3d(maxOffset.X, minOffset.Y, maxOffset.Z), // 5: 前右上
|
||||
new Point3d(maxOffset.X, maxOffset.Y, maxOffset.Z), // 6: 后右上
|
||||
new Point3d(minOffset.X, maxOffset.Y, maxOffset.Z), // 7: 后左上
|
||||
};
|
||||
|
||||
// 对象名必须与该对象的面一起输出;否则 obj2gltf 会把全部面归到最后一个对象
|
||||
_faces.AppendLine($"o {solidName}");
|
||||
|
||||
var vertexIndices = vertices.Select(GetOrCreateVertexIndex).ToArray();
|
||||
var normalIndices = new int[]
|
||||
{
|
||||
GetOrCreateNormalIndex(new Vector3d(0, 0, -1)),
|
||||
GetOrCreateNormalIndex(new Vector3d(0, 0, 1)),
|
||||
GetOrCreateNormalIndex(new Vector3d(0, -1, 0)),
|
||||
GetOrCreateNormalIndex(new Vector3d(0, 1, 0)),
|
||||
GetOrCreateNormalIndex(new Vector3d(-1, 0, 0)),
|
||||
GetOrCreateNormalIndex(new Vector3d(1, 0, 0)),
|
||||
};
|
||||
|
||||
// 底面 (Z min)
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[0]} {vertexIndices[2]}//{normalIndices[0]} {vertexIndices[1]}//{normalIndices[0]}");
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[0]} {vertexIndices[3]}//{normalIndices[0]} {vertexIndices[2]}//{normalIndices[0]}");
|
||||
|
||||
// 顶面 (Z max)
|
||||
_faces.AppendLine($"f {vertexIndices[4]}//{normalIndices[1]} {vertexIndices[5]}//{normalIndices[1]} {vertexIndices[6]}//{normalIndices[1]}");
|
||||
_faces.AppendLine($"f {vertexIndices[4]}//{normalIndices[1]} {vertexIndices[6]}//{normalIndices[1]} {vertexIndices[7]}//{normalIndices[1]}");
|
||||
|
||||
// 前面 (Y min)
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[2]} {vertexIndices[1]}//{normalIndices[2]} {vertexIndices[5]}//{normalIndices[2]}");
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[2]} {vertexIndices[5]}//{normalIndices[2]} {vertexIndices[4]}//{normalIndices[2]}");
|
||||
|
||||
// 后面 (Y max)
|
||||
_faces.AppendLine($"f {vertexIndices[2]}//{normalIndices[3]} {vertexIndices[3]}//{normalIndices[3]} {vertexIndices[7]}//{normalIndices[3]}");
|
||||
_faces.AppendLine($"f {vertexIndices[2]}//{normalIndices[3]} {vertexIndices[7]}//{normalIndices[3]} {vertexIndices[6]}//{normalIndices[3]}");
|
||||
|
||||
// 左面 (X min)
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[4]} {vertexIndices[4]}//{normalIndices[4]} {vertexIndices[7]}//{normalIndices[4]}");
|
||||
_faces.AppendLine($"f {vertexIndices[0]}//{normalIndices[4]} {vertexIndices[7]}//{normalIndices[4]} {vertexIndices[3]}//{normalIndices[4]}");
|
||||
|
||||
// 右面 (X max)
|
||||
_faces.AppendLine($"f {vertexIndices[1]}//{normalIndices[5]} {vertexIndices[2]}//{normalIndices[5]} {vertexIndices[6]}//{normalIndices[5]}");
|
||||
_faces.AppendLine($"f {vertexIndices[1]}//{normalIndices[5]} {vertexIndices[6]}//{normalIndices[5]} {vertexIndices[5]}//{normalIndices[5]}");
|
||||
|
||||
_faceCount += 12;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存为 OBJ 文件
|
||||
/// </summary>
|
||||
public void Save(string outputPath)
|
||||
{
|
||||
var content = new StringBuilder();
|
||||
|
||||
// 文件头
|
||||
content.AppendLine("# Furniture Exporter OBJ File (v2.0 - True Geometry)");
|
||||
content.AppendLine($"# Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||||
content.AppendLine($"# Solids: {_solidCount}");
|
||||
content.AppendLine($"# Vertices: {_vertexCount}");
|
||||
content.AppendLine($"# Normals: {_normalCount}");
|
||||
content.AppendLine($"# Faces: {_faceCount}");
|
||||
content.AppendLine();
|
||||
|
||||
// 顶点
|
||||
content.Append(_vertices);
|
||||
content.AppendLine();
|
||||
|
||||
// 法线
|
||||
content.Append(_normals);
|
||||
content.AppendLine();
|
||||
|
||||
// 面(包含对象分组标记)
|
||||
content.Append(_faces);
|
||||
|
||||
File.WriteAllText(outputPath, content.ToString(), Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取统计信息
|
||||
/// </summary>
|
||||
public (int Solids, int Vertices, int Normals, int Faces) GetStats()
|
||||
{
|
||||
return (_solidCount, _vertexCount, _normalCount, _faceCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出多个实体到 OBJ 文件
|
||||
/// </summary>
|
||||
/// <param name="solidIds">实体 ID 数组</param>
|
||||
/// <param name="outputPath">输出文件路径</param>
|
||||
/// <param name="originOffset">原点偏移(可选,用于将模型移动到世界原点)</param>
|
||||
/// <param name="meshDensity">网格密度因子(可选,默认 45)</param>
|
||||
/// <param name="maxSubdivisions">最大细分次数(可选,默认 20000)</param>
|
||||
public static void Export(
|
||||
ObjectId[] solidIds,
|
||||
string outputPath,
|
||||
Point3d? originOffset = null,
|
||||
double meshDensity = 45,
|
||||
int maxSubdivisions = 20000)
|
||||
{
|
||||
var exporter = new ObjExporter();
|
||||
|
||||
if (originOffset.HasValue)
|
||||
{
|
||||
exporter.SetOriginOffset(originOffset.Value);
|
||||
}
|
||||
|
||||
exporter.SetMeshDensity(meshDensity, maxSubdivisions);
|
||||
|
||||
var db = solidIds[0].Database;
|
||||
|
||||
using (var tr = db.TransactionManager.StartTransaction())
|
||||
{
|
||||
int index = 0;
|
||||
foreach (var id in solidIds)
|
||||
{
|
||||
var solid = tr.GetObject(id, OpenMode.ForRead) as Solid3d;
|
||||
if (solid != null)
|
||||
{
|
||||
exporter.AddSolid(solid, $"panel_{index:D3}");
|
||||
index++;
|
||||
}
|
||||
}
|
||||
tr.Commit();
|
||||
}
|
||||
|
||||
exporter.Save(outputPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出多个实体(3D Solid / BlockReference)到 OBJ 文件
|
||||
/// </summary>
|
||||
public static void ExportEntities(
|
||||
ObjectId[] entityIds,
|
||||
string outputPath,
|
||||
Database db,
|
||||
Point3d? originOffset = null,
|
||||
double meshDensity = 45,
|
||||
int maxSubdivisions = 20000)
|
||||
{
|
||||
var exporter = new ObjExporter();
|
||||
|
||||
if (originOffset.HasValue)
|
||||
{
|
||||
exporter.SetOriginOffset(originOffset.Value);
|
||||
}
|
||||
|
||||
exporter.SetMeshDensity(meshDensity, maxSubdivisions);
|
||||
|
||||
using (var tr = db.TransactionManager.StartTransaction())
|
||||
{
|
||||
int index = 0;
|
||||
foreach (var id in entityIds)
|
||||
{
|
||||
var entity = tr.GetObject(id, OpenMode.ForRead) as Entity;
|
||||
if (entity == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
exporter.AddEntity(entity, $"asset_{index:D3}");
|
||||
index++;
|
||||
}
|
||||
tr.Commit();
|
||||
}
|
||||
|
||||
exporter.Save(outputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,226 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
public static class PanelFaceSemanticResolver
|
||||
{
|
||||
private const double SurfaceFaceToleranceMm = 3.0;
|
||||
private const double InteriorFaceToleranceMm = 0.8;
|
||||
|
||||
public static string[] ResolveSurfaceFaces(
|
||||
BoundingBox3D? boundingBox,
|
||||
IReadOnlyList<double>? position,
|
||||
IReadOnlyList<double>? normal,
|
||||
double depth)
|
||||
{
|
||||
if (boundingBox?.Min?.Length != 3 || boundingBox.Max?.Length != 3)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var center = ToVector3(position);
|
||||
var axis = Normalize(ToVector3(normal));
|
||||
if (center == null || axis == null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var faces = new List<string>();
|
||||
var halfDepth = Math.Max(0, depth) / 2.0;
|
||||
var dominantFaces = ResolveDominantAxisFaces(axis);
|
||||
|
||||
if (halfDepth > 0.01)
|
||||
{
|
||||
AddFace(
|
||||
faces,
|
||||
ResolveNearestSurfaceFace(
|
||||
boundingBox,
|
||||
Offset(center, axis, halfDepth),
|
||||
dominantFaces.Positive,
|
||||
dominantFaces.Negative,
|
||||
SurfaceFaceToleranceMm));
|
||||
AddFace(
|
||||
faces,
|
||||
ResolveNearestSurfaceFace(
|
||||
boundingBox,
|
||||
Offset(center, axis, -halfDepth),
|
||||
dominantFaces.Negative,
|
||||
dominantFaces.Positive,
|
||||
SurfaceFaceToleranceMm));
|
||||
}
|
||||
|
||||
if (faces.Count == 0)
|
||||
{
|
||||
AddFace(
|
||||
faces,
|
||||
ResolveNearestSurfaceFace(
|
||||
boundingBox,
|
||||
center,
|
||||
dominantFaces.Positive,
|
||||
dominantFaces.Negative,
|
||||
SurfaceFaceToleranceMm + InteriorFaceToleranceMm));
|
||||
}
|
||||
|
||||
return faces
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static string OppositeFace(string? face)
|
||||
{
|
||||
return face?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"left" => "right",
|
||||
"right" => "left",
|
||||
"front" => "back",
|
||||
"back" => "front",
|
||||
"top" => "bottom",
|
||||
"bottom" => "top",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddFace(ICollection<string> faces, string? face)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(face))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
faces.Add(face!.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static double[]? ToVector3(IReadOnlyList<double>? value)
|
||||
{
|
||||
if (value == null || value.Count < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new[] { value[0], value[1], value[2] };
|
||||
}
|
||||
|
||||
private static double[]? Normalize(double[]? value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var length = Math.Sqrt(value[0] * value[0] + value[1] * value[1] + value[2] * value[2]);
|
||||
if (length < 1e-9)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
value[0] / length,
|
||||
value[1] / length,
|
||||
value[2] / length,
|
||||
};
|
||||
}
|
||||
|
||||
private static double[] Offset(double[] origin, double[] direction, double distance)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
origin[0] + direction[0] * distance,
|
||||
origin[1] + direction[1] * distance,
|
||||
origin[2] + direction[2] * distance,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveNearestSurfaceFace(BoundingBox3D box, IReadOnlyList<double> point)
|
||||
{
|
||||
return ResolveNearestSurfaceFace(box, point, null, null, SurfaceFaceToleranceMm);
|
||||
}
|
||||
|
||||
private static string? ResolveNearestSurfaceFace(
|
||||
BoundingBox3D box,
|
||||
IReadOnlyList<double> point,
|
||||
string? preferredFace,
|
||||
string? secondaryFace,
|
||||
double tolerance)
|
||||
{
|
||||
var candidates = new List<(string Face, double Distance)>();
|
||||
|
||||
if (IsWithinRange(point[1], box.Min[1], box.Max[1], tolerance) &&
|
||||
IsWithinRange(point[2], box.Min[2], box.Max[2], tolerance))
|
||||
{
|
||||
candidates.Add(("left", Math.Abs(point[0] - box.Min[0])));
|
||||
candidates.Add(("right", Math.Abs(point[0] - box.Max[0])));
|
||||
}
|
||||
|
||||
if (IsWithinRange(point[0], box.Min[0], box.Max[0], tolerance) &&
|
||||
IsWithinRange(point[2], box.Min[2], box.Max[2], tolerance))
|
||||
{
|
||||
candidates.Add(("front", Math.Abs(point[1] - box.Min[1])));
|
||||
candidates.Add(("back", Math.Abs(point[1] - box.Max[1])));
|
||||
}
|
||||
|
||||
if (IsWithinRange(point[0], box.Min[0], box.Max[0], tolerance) &&
|
||||
IsWithinRange(point[1], box.Min[1], box.Max[1], tolerance))
|
||||
{
|
||||
candidates.Add(("bottom", Math.Abs(point[2] - box.Min[2])));
|
||||
candidates.Add(("top", Math.Abs(point[2] - box.Max[2])));
|
||||
}
|
||||
|
||||
var best = candidates
|
||||
.Where(item => item.Distance <= tolerance)
|
||||
.OrderBy(item => GetFacePriority(item.Face, preferredFace, secondaryFace))
|
||||
.ThenBy(item => item.Distance)
|
||||
.FirstOrDefault();
|
||||
|
||||
return string.IsNullOrWhiteSpace(best.Face) ? null : best.Face;
|
||||
}
|
||||
|
||||
private static (string Positive, string Negative) ResolveDominantAxisFaces(IReadOnlyList<double> axis)
|
||||
{
|
||||
var absX = Math.Abs(axis[0]);
|
||||
var absY = Math.Abs(axis[1]);
|
||||
var absZ = Math.Abs(axis[2]);
|
||||
|
||||
if (absX >= absY && absX >= absZ)
|
||||
{
|
||||
return axis[0] >= 0
|
||||
? ("right", "left")
|
||||
: ("left", "right");
|
||||
}
|
||||
|
||||
if (absY >= absX && absY >= absZ)
|
||||
{
|
||||
return axis[1] >= 0
|
||||
? ("back", "front")
|
||||
: ("front", "back");
|
||||
}
|
||||
|
||||
return axis[2] >= 0
|
||||
? ("top", "bottom")
|
||||
: ("bottom", "top");
|
||||
}
|
||||
|
||||
private static int GetFacePriority(string face, string? preferredFace, string? secondaryFace)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preferredFace) &&
|
||||
string.Equals(face, preferredFace, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(secondaryFace) &&
|
||||
string.Equals(face, secondaryFace, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static bool IsWithinRange(double value, double min, double max, double tolerance)
|
||||
{
|
||||
return value >= min - tolerance && value <= max + tolerance;
|
||||
}
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 从板件包围盒推导辅助装配吸附面。
|
||||
/// </summary>
|
||||
public static class SnapSurfaceBuilder
|
||||
{
|
||||
private static readonly FaceDefinition[] Faces =
|
||||
{
|
||||
new("left", 0, false, new[] { -1.0, 0.0, 0.0 }),
|
||||
new("right", 0, true, new[] { 1.0, 0.0, 0.0 }),
|
||||
new("front", 1, false, new[] { 0.0, -1.0, 0.0 }),
|
||||
new("back", 1, true, new[] { 0.0, 1.0, 0.0 }),
|
||||
new("bottom", 2, false, new[] { 0.0, 0.0, -1.0 }),
|
||||
new("top", 2, true, new[] { 0.0, 0.0, 1.0 }),
|
||||
};
|
||||
|
||||
public static List<SnapSurfaceInfo> BuildSnapSurfaces(IReadOnlyList<PanelInfo> panels)
|
||||
{
|
||||
var allSurfaces = new List<SnapSurfaceInfo>();
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
var panelSurfaces = BuildPanelSurfaces(panel);
|
||||
panel.SnapSurfaces = panelSurfaces;
|
||||
allSurfaces.AddRange(panelSurfaces);
|
||||
}
|
||||
|
||||
return allSurfaces;
|
||||
}
|
||||
|
||||
private static List<SnapSurfaceInfo> BuildPanelSurfaces(PanelInfo panel)
|
||||
{
|
||||
var (min, max, confidence) = ResolveBounds(panel);
|
||||
var surfaces = new List<SnapSurfaceInfo>(Faces.Length);
|
||||
|
||||
foreach (var face in Faces)
|
||||
{
|
||||
var center = new[]
|
||||
{
|
||||
Round((min[0] + max[0]) / 2.0),
|
||||
Round((min[1] + max[1]) / 2.0),
|
||||
Round((min[2] + max[2]) / 2.0),
|
||||
};
|
||||
|
||||
var boundsMin = new[] { min[0], min[1], min[2] };
|
||||
var boundsMax = new[] { max[0], max[1], max[2] };
|
||||
var planeValue = face.UseMax ? max[face.Axis] : min[face.Axis];
|
||||
center[face.Axis] = planeValue;
|
||||
boundsMin[face.Axis] = planeValue;
|
||||
boundsMax[face.Axis] = planeValue;
|
||||
|
||||
surfaces.Add(new SnapSurfaceInfo
|
||||
{
|
||||
Id = $"{panel.Id}_snap_{face.Name}",
|
||||
Face = face.Name,
|
||||
PanelId = panel.Id,
|
||||
Normal = face.Normal,
|
||||
Center = center,
|
||||
Bounds = new BoundingBox3D
|
||||
{
|
||||
Min = RoundAll(boundsMin),
|
||||
Max = RoundAll(boundsMax),
|
||||
},
|
||||
Confidence = confidence,
|
||||
});
|
||||
}
|
||||
|
||||
return surfaces;
|
||||
}
|
||||
|
||||
private static (double[] Min, double[] Max, double Confidence) ResolveBounds(PanelInfo panel)
|
||||
{
|
||||
if (panel.BoundingBox?.Min?.Length == 3 && panel.BoundingBox.Max?.Length == 3)
|
||||
{
|
||||
return (RoundAll(panel.BoundingBox.Min), RoundAll(panel.BoundingBox.Max), 1.0);
|
||||
}
|
||||
|
||||
var center = panel.Position?.Length == 3
|
||||
? panel.Position
|
||||
: new[] { 0.0, 0.0, 0.0 };
|
||||
var size = panel.Size?.Length == 3
|
||||
? panel.Size
|
||||
: new[] { 0.0, 0.0, 0.0 };
|
||||
|
||||
var min = new[]
|
||||
{
|
||||
center[0] - Math.Abs(size[0]) / 2.0,
|
||||
center[1] - Math.Abs(size[1]) / 2.0,
|
||||
center[2] - Math.Abs(size[2]) / 2.0,
|
||||
};
|
||||
var max = new[]
|
||||
{
|
||||
center[0] + Math.Abs(size[0]) / 2.0,
|
||||
center[1] + Math.Abs(size[1]) / 2.0,
|
||||
center[2] + Math.Abs(size[2]) / 2.0,
|
||||
};
|
||||
|
||||
return (RoundAll(min), RoundAll(max), 0.6);
|
||||
}
|
||||
|
||||
private static double[] RoundAll(IReadOnlyList<double> values)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
Round(values[0]),
|
||||
Round(values[1]),
|
||||
Round(values[2]),
|
||||
};
|
||||
}
|
||||
|
||||
private static double Round(double value)
|
||||
{
|
||||
return Math.Round(value, 2);
|
||||
}
|
||||
|
||||
private sealed class FaceDefinition
|
||||
{
|
||||
public FaceDefinition(string name, int axis, bool useMax, double[] normal)
|
||||
{
|
||||
Name = name;
|
||||
Axis = axis;
|
||||
UseMax = useMax;
|
||||
Normal = normal;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public int Axis { get; }
|
||||
public bool UseMax { get; }
|
||||
public double[] Normal { get; }
|
||||
}
|
||||
}
|
||||
@ -1,232 +0,0 @@
|
||||
# AutoCAD 家具导出插件
|
||||
|
||||
用于 AutoCAD 2024 的家具 3D 模型导出插件。当前主流程是按资产中心口径导出单板材、单组件或单五金件,用于后续辅助装配和规格款入库。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **单资产导出** - `FEXPORT` 默认导出单板材、单组件或单五金件
|
||||
- **板材语义保留** - 导出孔位、孔组、吸附图样和吸附面,供辅助装配使用
|
||||
- **五金规格提取** - 单五金模式保留几何签名、图层类型和实例信息
|
||||
- **整套兼容保留** - 旧整套家具导出迁移到 `FEXPORTPRODUCT`
|
||||
|
||||
## 系统要求
|
||||
|
||||
- AutoCAD 2024
|
||||
- Windows 10/11
|
||||
- 具备读取 `%AppData%\Autodesk\ApplicationPlugins` 的用户权限
|
||||
|
||||
## 安装方法
|
||||
|
||||
### 方法 1:手动安装发版包(推荐给产品/实施)
|
||||
|
||||
1. 完全关闭 AutoCAD 2024
|
||||
2. 解压 `dist/家具导出插件-v2.6.0-一键安装版.zip`
|
||||
3. 将整个 `FurnitureExporter.bundle` 文件夹复制到:
|
||||
|
||||
```text
|
||||
%AppData%\Autodesk\ApplicationPlugins\
|
||||
```
|
||||
|
||||
最终目录应为:
|
||||
|
||||
```text
|
||||
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle\Contents\FurnitureExporter.dll
|
||||
```
|
||||
|
||||
4. 如果系统提示是否覆盖旧文件,选择“全部替换”
|
||||
5. 重新打开 AutoCAD 2024
|
||||
6. 输入命令 `FEXPORT`,能识别即表示安装成功
|
||||
|
||||
### 方法 2:一键安装
|
||||
|
||||
双击 `dist/家具导出插件-v2.6.0-一键安装版/安装.bat`
|
||||
|
||||
脚本会自动:
|
||||
1. 复制插件到 `%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle`
|
||||
2. 写入当前用户的 AutoCAD 自启动项
|
||||
3. 追加插件目录到可信路径
|
||||
|
||||
### 方法 3:一键编译(开发)
|
||||
|
||||
双击运行 `build.cmd` 或在 PowerShell 中执行 `.\build.ps1`
|
||||
|
||||
脚本会自动:
|
||||
1. 检测 AutoCAD 2024 安装路径
|
||||
2. 编译插件
|
||||
3. 显示输出文件位置
|
||||
|
||||
如果自动检测失败,可以手动设置环境变量:
|
||||
```cmd
|
||||
set ACAD_PATH=D:\你的路径\AutoCAD 2024
|
||||
build.cmd
|
||||
```
|
||||
|
||||
### 方法 4:手动编译
|
||||
|
||||
```bash
|
||||
# 设置 AutoCAD 路径(根据实际安装位置修改)
|
||||
set ACAD_PATH=C:\Program Files\Autodesk\AutoCAD 2024
|
||||
|
||||
# 编译
|
||||
cd FurnitureExporter
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
### 临时加载(仅开发调试用)
|
||||
|
||||
1. 打开 AutoCAD 2024
|
||||
2. 输入命令 `NETLOAD`
|
||||
3. 选择 `FurnitureExporter\bin\Release\net48\FurnitureExporter.dll`
|
||||
|
||||
### 自动加载
|
||||
|
||||
将插件以 Bundle 形式放入 AutoCAD 插件目录,AutoCAD 启动时自动加载:
|
||||
|
||||
```
|
||||
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle
|
||||
```
|
||||
|
||||
安装后无需重复配置,后续每次启动 AutoCAD 会自动加载插件。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### FEXPORT - 导出单资产到资产中心
|
||||
|
||||
```
|
||||
命令: FEXPORT
|
||||
```
|
||||
|
||||
**操作步骤:**
|
||||
|
||||
1. **选择资产类型** - 板材 / 组件 / 五金
|
||||
2. **选择实体** - 框选该单资产包含的 3D Solid 或图块,按 Enter 确认
|
||||
3. **保存文件** - 选择 ZIP 保存位置
|
||||
|
||||
**输出文件:** `xxx_board_asset.zip` / `xxx_component_asset.zip` / `xxx_hardware_asset.zip`,包含:
|
||||
- `model.obj` - 单资产模型
|
||||
- `hardware.json` - 单资产结构化数据,包含 `exportMode: "single_asset"` 与 `assetTypeHint`
|
||||
|
||||
### FEXPORTPRODUCT - 整套家具兼容导出
|
||||
|
||||
```
|
||||
命令: FEXPORTPRODUCT
|
||||
```
|
||||
|
||||
仅用于旧产品链路兼容。新资产中心和辅助装配测试请优先使用 `FEXPORT`。
|
||||
|
||||
### FINFO - 查看实体信息
|
||||
|
||||
```
|
||||
命令: FINFO
|
||||
```
|
||||
|
||||
选择实体后显示其详细信息(类型、尺寸、图层等),用于调试。
|
||||
|
||||
## 输出格式
|
||||
|
||||
### hardware.json 示例
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.6",
|
||||
"exportedAt": "2026-05-11T10:30:00Z",
|
||||
"sourceFile": "桌腿.dwg",
|
||||
"software": "AutoCAD 2024",
|
||||
"pluginVersion": "2.6.0",
|
||||
"modelFile": "model.obj",
|
||||
"exportMode": "single_asset",
|
||||
"assetTypeHint": "COMPONENT",
|
||||
"sourceEntityCount": 1,
|
||||
"panelCount": 1,
|
||||
"snapPatterns": [],
|
||||
"snapSurfaces": [],
|
||||
"hardwareTypes": []
|
||||
}
|
||||
```
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
设计师 系统
|
||||
─────── ──────
|
||||
1. 打开 DWG 文件
|
||||
2. FEXPORT
|
||||
3. 选择资产类型(板材/组件/五金)
|
||||
4. 框选单个资产实体 ───────→
|
||||
5. 保存 ZIP ────────────────→ 上传到资产中心
|
||||
↓
|
||||
自动解析
|
||||
↓
|
||||
创建/更新资产族规格款
|
||||
↓
|
||||
保存到数据库
|
||||
↓
|
||||
辅助装配可用 ✓
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 编译
|
||||
|
||||
```bash
|
||||
cd FurnitureExporter
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
### 调试
|
||||
|
||||
1. Visual Studio 打开项目
|
||||
2. 设置调试程序为 `acad.exe`(AutoCAD 2024)
|
||||
3. F5 启动调试
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
FurnitureExporter/
|
||||
├── FurnitureExporter.csproj # 项目配置
|
||||
├── Commands.cs # AutoCAD 命令
|
||||
├── GlbExporter.cs # GLB 导出器
|
||||
├── HardwareAnalyzer.cs # 五金件分析器
|
||||
└── Models.cs # 数据模型
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 插件加载失败
|
||||
|
||||
- 确认 AutoCAD 版本为 2024
|
||||
- 确认已安装 .NET 6.0 Runtime
|
||||
- 检查 DLL 路径是否包含中文
|
||||
|
||||
### Q: 导出的模型显示不正确
|
||||
|
||||
- 确保板材是 3D Solid 实体
|
||||
- 检查模型单位是否为毫米
|
||||
|
||||
### Q: 五金件未被识别
|
||||
|
||||
- 确保五金件是 3D Solid 或图块引用
|
||||
- 检查是否正确框选了五金件
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v2.1.0
|
||||
- 新增倒角和圆角检测功能
|
||||
- 导出数据增加 `edgeFeatures` 字段
|
||||
- 优化 Brep 分析性能
|
||||
|
||||
### v2.0.0
|
||||
- 新增孔位自动检测(通过 Brep API)
|
||||
- 新增五金件-孔位-板材智能关联
|
||||
- 导出数据增加 `panels`、`holes` 字段
|
||||
- 支持孔位用途推断(铰链孔、偏心轮孔等)
|
||||
|
||||
### v1.0.0
|
||||
- 初始版本
|
||||
- 支持板材 GLB 导出
|
||||
- 支持五金件位置提取
|
||||
- 自动按几何特征分组
|
||||
|
||||
## 许可
|
||||
|
||||
MIT License
|
||||
@ -1,533 +0,0 @@
|
||||
# 家具导出插件 - 安装包生成脚本
|
||||
# 生成一个“解压后双击 安装.bat 即可安装”的简化安装包。
|
||||
#
|
||||
# 关键设计:
|
||||
# - 设计师侧仍然只接触 .bat(双击即可),但核心逻辑用 PowerShell 执行。
|
||||
# - 原因:部分机器的 AutoCAD Profile 名称可能包含 << >>,在 cmd/bat 的 for /f 子命令里
|
||||
# 很容易触发重定向解析,导致 TRUSTEDPATHS 写入失败 -> 插件无法自启动(每次都要 NETLOAD)。
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-TextFileUtf8NoBom {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
# Windows 下的脚本/文本统一 CRLF,避免 cmd/某些编辑器对 LF-only 解析异常
|
||||
$normalized = $Content.Replace("`r`n", "`n").Replace("`n", "`r`n")
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $normalized, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Write-TextFileUtf8Bom {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
# Windows PowerShell 5.1 对“UTF-8 无 BOM”的脚本兼容性很差(常见表现:中文乱码甚至解析报错)。
|
||||
# 设计师侧会通过 powershell.exe 执行 install.ps1 / uninstall.ps1,因此这里强制写入 UTF-8 BOM。
|
||||
$normalized = $Content.Replace("`r`n", "`n").Replace("`n", "`r`n")
|
||||
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
||||
[System.IO.File]::WriteAllText($Path, $normalized, $utf8Bom)
|
||||
}
|
||||
|
||||
function Write-TextFileGbk {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
# cmd 解析 bat 对编码/换行很敏感:使用 GBK + CRLF 兼容中文环境
|
||||
$normalized = $Content.Replace("`r`n", "`n").Replace("`n", "`r`n")
|
||||
$gbk = [System.Text.Encoding]::GetEncoding(936)
|
||||
[System.IO.File]::WriteAllText($Path, $normalized, $gbk)
|
||||
}
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " 家具导出插件 - 安装包生成器" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 路径设置
|
||||
$PluginName = "FurnitureExporter"
|
||||
$Version = "2.6.0"
|
||||
$SourceDir = Join-Path $PSScriptRoot "FurnitureExporter"
|
||||
$InstallerDir = Join-Path $PSScriptRoot "installer"
|
||||
$OutputDir = Join-Path $PSScriptRoot "dist"
|
||||
$OutputName = "家具导出插件-v${Version}-一键安装版"
|
||||
|
||||
$DllSourcePath = Join-Path $SourceDir "bin\\Release\\net48\\FurnitureExporter.dll"
|
||||
$PdbSourcePath = Join-Path $SourceDir "bin\\Release\\net48\\FurnitureExporter.pdb"
|
||||
|
||||
if (-not (Test-Path $DllSourcePath)) {
|
||||
Write-Host "❌ 未找到编译好的 DLL:$DllSourcePath" -ForegroundColor Red
|
||||
Write-Host "请先运行 build.ps1(或 dotnet build -c Release)编译插件" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 清理并创建安装目录
|
||||
if (Test-Path $InstallerDir) {
|
||||
Remove-Item -Path $InstallerDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $InstallerDir -Force | Out-Null
|
||||
|
||||
Write-Host "📦 正在准备安装包文件..." -ForegroundColor Yellow
|
||||
|
||||
# 1) 创建 Bundle 目录结构
|
||||
$BundleDir = Join-Path $InstallerDir "${PluginName}.bundle"
|
||||
$ContentsDir = Join-Path $BundleDir "Contents"
|
||||
New-Item -ItemType Directory -Path $ContentsDir -Force | Out-Null
|
||||
|
||||
# 2) 复制 DLL / PDB
|
||||
Copy-Item -Path $DllSourcePath -Destination $ContentsDir -Force
|
||||
if (Test-Path $PdbSourcePath) {
|
||||
Copy-Item -Path $PdbSourcePath -Destination $ContentsDir -Force
|
||||
}
|
||||
|
||||
# 3) PackageContents.xml
|
||||
$PackageXml = @"
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ApplicationPackage
|
||||
SchemaVersion="1.0"
|
||||
AutodeskProduct="AutoCAD"
|
||||
Name="$PluginName"
|
||||
Description="家具导出插件 v$Version - 默认资产中心单资产导出,保留整套兼容导出"
|
||||
Author="Furniture 3D System"
|
||||
AppVersion="$Version"
|
||||
FriendlyVersion="$Version"
|
||||
ProductType="Application"
|
||||
Version="$Version"
|
||||
ProductCode="{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}">
|
||||
|
||||
<CompanyDetails
|
||||
Name="Furniture 3D System"
|
||||
Email="support@example.com" />
|
||||
|
||||
<RuntimeRequirements
|
||||
OS="Win64"
|
||||
Platform="AutoCAD*"
|
||||
SeriesMin="R24.0"
|
||||
SeriesMax="R24.3" />
|
||||
|
||||
<Components Description="AutoCAD 2024">
|
||||
<RuntimeRequirements
|
||||
OS="Win64"
|
||||
Platform="AutoCAD*"
|
||||
SeriesMin="R24.0"
|
||||
SeriesMax="R24.3" />
|
||||
|
||||
<ComponentEntry
|
||||
AppName="$PluginName"
|
||||
Version="$Version"
|
||||
ModuleName="./Contents/$PluginName.dll"
|
||||
AppDescription="家具导出插件 - 默认资产中心单资产导出,保留整套兼容导出"
|
||||
LoadOnAutoCADStartup="True"
|
||||
AppType="Net" />
|
||||
</Components>
|
||||
|
||||
</ApplicationPackage>
|
||||
"@
|
||||
Write-TextFileUtf8NoBom -Path (Join-Path $BundleDir "PackageContents.xml") -Content $PackageXml
|
||||
|
||||
# 4) 安装/卸载 PowerShell 脚本(核心逻辑)
|
||||
$InstallPs1 = @'
|
||||
# 家具导出插件 - 一键安装脚本(PowerShell)
|
||||
# 目标:
|
||||
# 1) 复制 bundle 到当前用户:%AppData%\Autodesk\ApplicationPlugins
|
||||
# 2) 写入 AutoCAD 2024 自启动注册表(避免每次 NETLOAD)
|
||||
# 3) 追加可信路径 TRUSTEDPATHS(SECURELOAD=1 时必须,否则不自动加载)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Text)
|
||||
Write-Host $Text -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Test-IsAdmin {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Add-TrustedPath {
|
||||
param(
|
||||
[AllowNull()][string]$Existing,
|
||||
[Parameter(Mandatory = $true)][string]$PathToAdd
|
||||
)
|
||||
|
||||
$normalizedAdd = $PathToAdd.Trim().TrimEnd('\')
|
||||
$items = @()
|
||||
|
||||
if ($Existing) {
|
||||
$items = $Existing.Split(';') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
}
|
||||
|
||||
if (-not $items -or $items.Count -eq 0) {
|
||||
# 保留 AutoCAD 常见默认值,避免误删用户原本设置
|
||||
$items = @("APP_PATH")
|
||||
}
|
||||
|
||||
$exists = $false
|
||||
foreach ($it in $items) {
|
||||
if ($it.Trim().TrimEnd('\').Equals($normalizedAdd, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$exists = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $exists) {
|
||||
$items += $PathToAdd
|
||||
}
|
||||
|
||||
# 去重(忽略大小写 + 末尾反斜杠)
|
||||
$seen = New-Object "System.Collections.Generic.HashSet[string]" ([StringComparer]::OrdinalIgnoreCase)
|
||||
$dedup = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($it in $items) {
|
||||
$k = $it.Trim().TrimEnd('\')
|
||||
if ($seen.Add($k)) {
|
||||
$dedup.Add($it.Trim())
|
||||
}
|
||||
}
|
||||
|
||||
return ($dedup -join ";")
|
||||
}
|
||||
|
||||
Write-Step "[0/3] 环境检查..."
|
||||
|
||||
if (Get-Process acad -ErrorAction SilentlyContinue) {
|
||||
Write-Host ""
|
||||
Write-Host "注意:检测到 AutoCAD 正在运行 (acad.exe)。" -ForegroundColor Yellow
|
||||
Write-Host "为避免 DLL 被占用导致复制/删除失败,建议先完全关闭 AutoCAD 再安装。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if (Test-IsAdmin) {
|
||||
Write-Host ""
|
||||
Write-Host "注意:你正在以管理员身份运行。" -ForegroundColor Yellow
|
||||
Write-Host "本插件按“当前 Windows 用户”安装(%AppData% + HKCU 注册表)。" -ForegroundColor Yellow
|
||||
Write-Host "如果不是在设计师账号下运行,安装后可能在设计师账号下不生效。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$bundleSrc = Join-Path $scriptDir "FurnitureExporter.bundle"
|
||||
|
||||
# 兼容“解压覆盖旧目录”导致的套娃结构:FurnitureExporter.bundle\FurnitureExporter.bundle\...
|
||||
if (-not (Test-Path (Join-Path $bundleSrc "PackageContents.xml"))) {
|
||||
$nested = Join-Path $bundleSrc "FurnitureExporter.bundle"
|
||||
if (Test-Path (Join-Path $nested "PackageContents.xml")) {
|
||||
$bundleSrc = $nested
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $bundleSrc "PackageContents.xml"))) {
|
||||
Write-Host "未找到 FurnitureExporter.bundle(请先解压安装包,再运行 安装.bat)。" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$appPlugins = Join-Path $env:APPDATA "Autodesk\ApplicationPlugins"
|
||||
$bundleDst = Join-Path $appPlugins "FurnitureExporter.bundle"
|
||||
$dllPath = Join-Path $bundleDst "Contents\FurnitureExporter.dll"
|
||||
$trustedDir = Join-Path $bundleDst "Contents"
|
||||
|
||||
Write-Step "[1/3] 复制插件..."
|
||||
New-Item -ItemType Directory -Path $appPlugins -Force | Out-Null
|
||||
|
||||
if (Test-Path $bundleDst) {
|
||||
try {
|
||||
Remove-Item -Path $bundleDst -Recurse -Force
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Host "删除旧版本失败:$bundleDst" -ForegroundColor Red
|
||||
Write-Host "常见原因:AutoCAD 未完全关闭/文件被占用/安全软件拦截。" -ForegroundColor Yellow
|
||||
Write-Host "请关闭 AutoCAD 后重试,或手动删除上述目录。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Copy-Item -Path $bundleSrc -Destination $bundleDst -Recurse -Force
|
||||
|
||||
if (-not (Test-Path $dllPath)) {
|
||||
Write-Host "安装失败:未找到 DLL:$dllPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Step "[2/3] 注册自启动/可信路径..."
|
||||
|
||||
$acadRoot = "HKCU:\Software\Autodesk\AutoCAD\R24.3"
|
||||
$curVerObj = Get-ItemProperty -Path $acadRoot -Name CurVer -ErrorAction SilentlyContinue
|
||||
$acadCur = $curVerObj.CurVer
|
||||
|
||||
if (-not $acadCur) {
|
||||
Write-Host "未检测到 AutoCAD 2024 注册表信息:$acadRoot\CurVer" -ForegroundColor Yellow
|
||||
Write-Host "已完成文件复制,但未写入自启动注册。请确认已至少启动过 AutoCAD 2024 一次。" -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
$acadBase = Join-Path $acadRoot $acadCur
|
||||
|
||||
# 1) Applications 自启动(.NET 插件标准方式)
|
||||
$appKey = Join-Path $acadBase "Applications\FurnitureExporter"
|
||||
New-Item -Path $appKey -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "DESCRIPTION" -PropertyType String -Value "家具导出插件 v$Version" -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "LOADCTRLS" -PropertyType DWord -Value 2 -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "MANAGED" -PropertyType DWord -Value 1 -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "LOADER" -PropertyType String -Value $dllPath -Force | Out-Null
|
||||
|
||||
# 2) 可信路径:只加入本插件 Contents,避免把整个 ApplicationPlugins 目录设为可信
|
||||
$profilesRoot = Join-Path $acadBase "Profiles"
|
||||
$profiles = Get-ChildItem -Path $profilesRoot -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($p in $profiles) {
|
||||
$varsKey = Join-Path $p.PSPath "Variables"
|
||||
if (-not (Test-Path $varsKey)) { continue }
|
||||
|
||||
$vars = Get-ItemProperty -Path $varsKey -ErrorAction SilentlyContinue
|
||||
$existingTrusted = $null
|
||||
if ($vars.PSObject.Properties.Name -contains "TRUSTEDPATHS") {
|
||||
$existingTrusted = [string]$vars.TRUSTEDPATHS
|
||||
}
|
||||
|
||||
$newTrusted = Add-TrustedPath -Existing $existingTrusted -PathToAdd $trustedDir
|
||||
if ($existingTrusted -ne $newTrusted) {
|
||||
New-ItemProperty -Path $varsKey -Name "TRUSTEDPATHS" -PropertyType String -Value $newTrusted -Force | Out-Null
|
||||
}
|
||||
|
||||
# 如果 SECURELOAD=2,会导致即使加入可信路径也不加载(尽量只从 2 降到 1)
|
||||
if ($vars.PSObject.Properties.Name -contains "SECURELOAD") {
|
||||
if ([string]$vars.SECURELOAD -eq "2") {
|
||||
New-ItemProperty -Path $varsKey -Name "SECURELOAD" -PropertyType String -Value "1" -Force | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "[3/3] 完成"
|
||||
Write-Host ""
|
||||
Write-Host "安装完成。请完全关闭 AutoCAD 后重新启动。" -ForegroundColor Green
|
||||
Write-Host "启动后命令行输入:FEXPORT(单资产)或 FEXPORTPRODUCT(整套兼容)" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
'@
|
||||
Write-TextFileUtf8Bom -Path (Join-Path $InstallerDir "install.ps1") -Content $InstallPs1
|
||||
|
||||
$UninstallPs1 = @'
|
||||
# 家具导出插件 - 一键卸载脚本(PowerShell)
|
||||
# 目标:删除当前用户 ApplicationPlugins 下的 bundle,并清理自启动注册/可信路径。
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Text)
|
||||
Write-Host $Text -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Remove-TrustedPath {
|
||||
param(
|
||||
[AllowNull()][string]$Existing,
|
||||
[Parameter(Mandatory = $true)][string]$PathToRemove
|
||||
)
|
||||
|
||||
if (-not $Existing) { return $Existing }
|
||||
|
||||
$normalizedRemove = $PathToRemove.Trim().TrimEnd('\')
|
||||
$items = $Existing.Split(';') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
|
||||
$kept = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($it in $items) {
|
||||
$k = $it.Trim().TrimEnd('\')
|
||||
if (-not $k.Equals($normalizedRemove, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$kept.Add($it.Trim())
|
||||
}
|
||||
}
|
||||
|
||||
return ($kept -join ";")
|
||||
}
|
||||
|
||||
Write-Step "[0/3] 环境检查..."
|
||||
|
||||
if (Get-Process acad -ErrorAction SilentlyContinue) {
|
||||
Write-Host ""
|
||||
Write-Host "注意:检测到 AutoCAD 正在运行 (acad.exe)。" -ForegroundColor Yellow
|
||||
Write-Host "如卸载失败,请先完全关闭 AutoCAD 后再重试。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$appPlugins = Join-Path $env:APPDATA "Autodesk\ApplicationPlugins"
|
||||
$bundleDst = Join-Path $appPlugins "FurnitureExporter.bundle"
|
||||
$trustedDir = Join-Path $bundleDst "Contents"
|
||||
|
||||
Write-Step "[1/3] 删除插件文件..."
|
||||
if (Test-Path $bundleDst) {
|
||||
try {
|
||||
Remove-Item -Path $bundleDst -Recurse -Force
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Host "删除失败:$bundleDst" -ForegroundColor Red
|
||||
Write-Host "常见原因:AutoCAD 未完全关闭/文件被占用/安全软件拦截。" -ForegroundColor Yellow
|
||||
Write-Host "你也可以手动删除该目录后,再运行 卸载.bat 清理注册表。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "[2/3] 清理注册表..."
|
||||
|
||||
$acadRoot = "HKCU:\Software\Autodesk\AutoCAD\R24.3"
|
||||
$curVerObj = Get-ItemProperty -Path $acadRoot -Name CurVer -ErrorAction SilentlyContinue
|
||||
$acadCur = $curVerObj.CurVer
|
||||
|
||||
if ($acadCur) {
|
||||
$acadBase = Join-Path $acadRoot $acadCur
|
||||
|
||||
# Applications 自启动项
|
||||
$appKey = Join-Path $acadBase "Applications\FurnitureExporter"
|
||||
if (Test-Path $appKey) {
|
||||
Remove-Item -Path $appKey -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# TRUSTEDPATHS 移除插件 Contents(只移除我们加的这一项,避免影响其他插件)
|
||||
$profilesRoot = Join-Path $acadBase "Profiles"
|
||||
$profiles = Get-ChildItem -Path $profilesRoot -ErrorAction SilentlyContinue
|
||||
foreach ($p in $profiles) {
|
||||
$varsKey = Join-Path $p.PSPath "Variables"
|
||||
if (-not (Test-Path $varsKey)) { continue }
|
||||
|
||||
$vars = Get-ItemProperty -Path $varsKey -ErrorAction SilentlyContinue
|
||||
if (-not ($vars.PSObject.Properties.Name -contains "TRUSTEDPATHS")) { continue }
|
||||
|
||||
$existingTrusted = [string]$vars.TRUSTEDPATHS
|
||||
$newTrusted = Remove-TrustedPath -Existing $existingTrusted -PathToRemove $trustedDir
|
||||
|
||||
if ($existingTrusted -ne $newTrusted) {
|
||||
if ([string]::IsNullOrWhiteSpace($newTrusted)) {
|
||||
Remove-ItemProperty -Path $varsKey -Name "TRUSTEDPATHS" -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
New-ItemProperty -Path $varsKey -Name "TRUSTEDPATHS" -PropertyType String -Value $newTrusted -Force | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "未检测到 AutoCAD 2024 注册表信息,跳过注册表清理。" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Step "[3/3] 完成"
|
||||
Write-Host ""
|
||||
Write-Host "卸载完成。请完全关闭 AutoCAD 后重新启动。" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
'@
|
||||
Write-TextFileUtf8Bom -Path (Join-Path $InstallerDir "uninstall.ps1") -Content $UninstallPs1
|
||||
|
||||
# 5) bat 入口(只负责启动 PowerShell 脚本)
|
||||
$InstallBat = @'
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
title 家具导出插件 安装程序
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if not exist "%SCRIPT_DIR%install.ps1" (
|
||||
echo 未找到 install.ps1
|
||||
echo 请先解压安装包到当前目录后再运行。
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%install.ps1"
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
'@
|
||||
Write-TextFileGbk -Path (Join-Path $InstallerDir "安装.bat") -Content $InstallBat
|
||||
|
||||
$UninstallBat = @'
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
title 家具导出插件 卸载程序
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if not exist "%SCRIPT_DIR%uninstall.ps1" (
|
||||
echo 未找到 uninstall.ps1
|
||||
echo 请先解压安装包到当前目录后再运行。
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%uninstall.ps1"
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
'@
|
||||
Write-TextFileGbk -Path (Join-Path $InstallerDir "卸载.bat") -Content $UninstallBat
|
||||
|
||||
# 6) 使用说明
|
||||
$Readme = @"
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
家具导出插件 v$Version - 安装指南
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
安装步骤(只需30秒)
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
1) 解压安装包
|
||||
2) 双击运行:安装.bat
|
||||
3) 完全关闭 AutoCAD 2024 后重新启动
|
||||
|
||||
卸载步骤(只需10秒)
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
1) 双击运行:卸载.bat
|
||||
2) 完全关闭 AutoCAD 2024 后重新启动
|
||||
|
||||
使用方法
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
打开 AutoCAD 后,命令行输入:
|
||||
FEXPORT -> 单资产导出到资产中心(板材 / 组件 / 五金)
|
||||
FEXPORTASSET -> 单资产导出别名
|
||||
FEXPORTPRODUCT -> 整套家具兼容导出
|
||||
FEXPORTBUNDLE -> 整套家具兼容导出别名
|
||||
FINFO -> 查看实体信息
|
||||
|
||||
安装脚本做了什么(便于 IT/排查)
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
1) 复制到:
|
||||
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle
|
||||
2) 注册自启动(HKCU):
|
||||
HKCU\Software\Autodesk\AutoCAD\R24.3\<CurVer>\Applications\FurnitureExporter
|
||||
3) 追加可信路径(HKCU / 每个 Profile):
|
||||
TRUSTEDPATHS += ...\FurnitureExporter.bundle\Contents
|
||||
|
||||
常见问题
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
Q: 提示“未知命令 FEXPORT / FEXPORTPRODUCT”
|
||||
A: 1) 确认已重启 AutoCAD(关闭再打开)
|
||||
2) 确认插件目录存在:
|
||||
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle\Contents\FurnitureExporter.dll
|
||||
3) 如果 SECURELOAD=2,脚本会将其降到 1;如仍无效请截图安装日志给开发排查
|
||||
|
||||
版本: v$Version
|
||||
"@
|
||||
Write-TextFileUtf8NoBom -Path (Join-Path $InstallerDir "使用说明.txt") -Content $Readme
|
||||
|
||||
# 7) 打包为 ZIP(并同时输出一个可直接双击测试的目录)
|
||||
Write-Host "📦 正在生成安装包..." -ForegroundColor Yellow
|
||||
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
$ZipPath = Join-Path $OutputDir "$OutputName.zip"
|
||||
Compress-Archive -Path (Join-Path $InstallerDir "*") -DestinationPath $ZipPath -Force
|
||||
|
||||
$StagingDir = Join-Path $OutputDir $OutputName
|
||||
if (Test-Path $StagingDir) {
|
||||
Remove-Item -Path $StagingDir -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
|
||||
Copy-Item -Path (Join-Path $InstallerDir "*") -Destination $StagingDir -Recurse -Force
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " ✅ 安装包生成成功!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "输出文件:" -ForegroundColor Cyan
|
||||
Write-Host " - $ZipPath"
|
||||
Write-Host " - $StagingDir"
|
||||
Write-Host ""
|
||||
@ -1,87 +0,0 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
echo ========================================
|
||||
echo 家具导出插件编译工具
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: 检测 AutoCAD 安装路径
|
||||
set "FOUND_PATH="
|
||||
|
||||
:: 检查环境变量
|
||||
if defined ACAD_PATH (
|
||||
if exist "%ACAD_PATH%\accoremgd.dll" (
|
||||
set "FOUND_PATH=%ACAD_PATH%"
|
||||
echo [√] 使用环境变量 ACAD_PATH: %ACAD_PATH%
|
||||
goto :build
|
||||
)
|
||||
)
|
||||
|
||||
:: 自动检测常见路径
|
||||
echo [*] 正在检测 AutoCAD 2024 安装路径...
|
||||
|
||||
for %%P in (
|
||||
"C:\Program Files\Autodesk\AutoCAD 2024"
|
||||
"D:\Program Files\Autodesk\AutoCAD 2024"
|
||||
"E:\Program Files\Autodesk\AutoCAD 2024"
|
||||
"C:\Autodesk\AutoCAD 2024"
|
||||
"D:\Autodesk\AutoCAD 2024"
|
||||
"E:\Autodesk\AutoCAD 2024"
|
||||
) do (
|
||||
if exist "%%~P\accoremgd.dll" (
|
||||
set "FOUND_PATH=%%~P"
|
||||
echo [√] 找到 AutoCAD 2024: %%~P
|
||||
goto :build
|
||||
)
|
||||
)
|
||||
|
||||
:: 未找到,请求手动输入
|
||||
echo.
|
||||
echo [×] 未找到 AutoCAD 2024 安装目录!
|
||||
echo.
|
||||
echo 请输入 AutoCAD 2024 安装路径:
|
||||
set /p "FOUND_PATH=路径: "
|
||||
|
||||
if not exist "%FOUND_PATH%\accoremgd.dll" (
|
||||
echo [×] 路径无效或不包含 AutoCAD 2024 文件
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:build
|
||||
:: 设置环境变量
|
||||
set "ACAD_PATH=%FOUND_PATH%"
|
||||
|
||||
echo.
|
||||
echo [*] 开始编译...
|
||||
echo.
|
||||
|
||||
:: 编译
|
||||
pushd "%~dp0FurnitureExporter"
|
||||
dotnet build -c Release
|
||||
|
||||
if %ERRORLEVEL% EQU 0 (
|
||||
echo.
|
||||
echo ========================================
|
||||
echo 编译成功!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 输出文件位置:
|
||||
echo %~dp0FurnitureExporter\bin\Release\net6.0-windows\FurnitureExporter.dll
|
||||
echo.
|
||||
echo 使用方法:
|
||||
echo 1. 打开 AutoCAD 2024
|
||||
echo 2. 输入命令: NETLOAD
|
||||
echo 3. 选择上述 DLL 文件
|
||||
echo 4. 使用命令: FEXPORT 导出单资产模型
|
||||
echo 旧整套兼容导出: FEXPORTPRODUCT
|
||||
echo.
|
||||
) else (
|
||||
echo.
|
||||
echo [×] 编译失败!请检查错误信息。
|
||||
)
|
||||
|
||||
popd
|
||||
pause
|
||||
@ -1,99 +0,0 @@
|
||||
# 家具导出插件编译脚本
|
||||
# 自动检测 AutoCAD 安装路径并编译插件
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " 家具导出插件编译工具" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 检测 AutoCAD 安装路径
|
||||
$acadPaths = @(
|
||||
"C:\Program Files\Autodesk\AutoCAD 2024",
|
||||
"D:\Program Files\Autodesk\AutoCAD 2024",
|
||||
"E:\Program Files\Autodesk\AutoCAD 2024",
|
||||
"C:\Autodesk\AutoCAD 2024",
|
||||
"D:\Autodesk\AutoCAD 2024",
|
||||
"E:\Autodesk\AutoCAD 2024"
|
||||
)
|
||||
|
||||
$foundPath = $null
|
||||
|
||||
# 检查环境变量
|
||||
if ($env:ACAD_PATH -and (Test-Path "$env:ACAD_PATH\accoremgd.dll")) {
|
||||
$foundPath = $env:ACAD_PATH
|
||||
Write-Host "[√] 使用环境变量 ACAD_PATH: $foundPath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
# 自动检测
|
||||
Write-Host "[*] 正在检测 AutoCAD 2024 安装路径..." -ForegroundColor Yellow
|
||||
|
||||
foreach ($path in $acadPaths) {
|
||||
if (Test-Path "$path\accoremgd.dll") {
|
||||
$foundPath = $path
|
||||
Write-Host "[√] 找到 AutoCAD 2024: $foundPath" -ForegroundColor Green
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $foundPath) {
|
||||
Write-Host ""
|
||||
Write-Host "[×] 未找到 AutoCAD 2024 安装目录!" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "请执行以下操作之一:" -ForegroundColor Yellow
|
||||
Write-Host " 1. 设置环境变量 ACAD_PATH 指向 AutoCAD 2024 安装目录"
|
||||
Write-Host " 例如: `$env:ACAD_PATH = 'D:\你的路径\AutoCAD 2024'"
|
||||
Write-Host ""
|
||||
Write-Host " 2. 或者直接输入路径:" -ForegroundColor Yellow
|
||||
$inputPath = Read-Host " 请输入 AutoCAD 2024 安装路径"
|
||||
|
||||
if ($inputPath -and (Test-Path "$inputPath\accoremgd.dll")) {
|
||||
$foundPath = $inputPath
|
||||
Write-Host "[√] 路径有效: $foundPath" -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host "[×] 路径无效或不包含 AutoCAD 2024 文件" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# 设置环境变量
|
||||
$env:ACAD_PATH = $foundPath
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[*] 开始编译..." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# 编译
|
||||
Push-Location "$PSScriptRoot\FurnitureExporter"
|
||||
try {
|
||||
dotnet build -c Release
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host " 编译成功!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "输出文件位置:" -ForegroundColor Cyan
|
||||
Write-Host " $PSScriptRoot\FurnitureExporter\bin\Release\net48\FurnitureExporter.dll"
|
||||
Write-Host ""
|
||||
Write-Host "使用方法:" -ForegroundColor Cyan
|
||||
Write-Host " 1. 打开 AutoCAD 2024"
|
||||
Write-Host " 2. 输入命令: NETLOAD"
|
||||
Write-Host " 3. 选择上述 DLL 文件"
|
||||
Write-Host " 4. 使用命令: FEXPORT 导出单资产模型"
|
||||
Write-Host " 旧整套兼容导出: FEXPORTPRODUCT"
|
||||
Write-Host ""
|
||||
}
|
||||
else {
|
||||
Write-Host ""
|
||||
Write-Host "[×] 编译失败!请检查错误信息。" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ApplicationPackage
|
||||
SchemaVersion="1.0"
|
||||
AutodeskProduct="AutoCAD"
|
||||
Name="FurnitureExporter"
|
||||
Description="家具导出插件 v2.6.0 - 默认资产中心单资产导出,保留整套兼容导出"
|
||||
Author="Furniture 3D System"
|
||||
AppVersion="2.6.0"
|
||||
FriendlyVersion="2.6.0"
|
||||
ProductType="Application"
|
||||
Version="2.6.0"
|
||||
ProductCode="{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}">
|
||||
|
||||
<CompanyDetails
|
||||
Name="Furniture 3D System"
|
||||
Email="support@example.com" />
|
||||
|
||||
<RuntimeRequirements
|
||||
OS="Win64"
|
||||
Platform="AutoCAD*"
|
||||
SeriesMin="R24.0"
|
||||
SeriesMax="R24.3" />
|
||||
|
||||
<Components Description="AutoCAD 2024">
|
||||
<RuntimeRequirements
|
||||
OS="Win64"
|
||||
Platform="AutoCAD*"
|
||||
SeriesMin="R24.0"
|
||||
SeriesMax="R24.3" />
|
||||
|
||||
<ComponentEntry
|
||||
AppName="FurnitureExporter"
|
||||
Version="2.6.0"
|
||||
ModuleName="./Contents/FurnitureExporter.dll"
|
||||
AppDescription="家具导出插件 - 默认资产中心单资产导出,保留整套兼容导出"
|
||||
LoadOnAutoCADStartup="True"
|
||||
AppType="Net" />
|
||||
</Components>
|
||||
|
||||
</ApplicationPackage>
|
||||
@ -1,178 +0,0 @@
|
||||
# 家具导出插件 - 一键安装脚本(PowerShell)
|
||||
# 目标:
|
||||
# 1) 复制 bundle 到当前用户:%AppData%\Autodesk\ApplicationPlugins
|
||||
# 2) 写入 AutoCAD 2024 自启动注册表(避免每次 NETLOAD)
|
||||
# 3) 追加可信路径 TRUSTEDPATHS(SECURELOAD=1 时必须,否则不自动加载)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Text)
|
||||
Write-Host $Text -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Test-IsAdmin {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Add-TrustedPath {
|
||||
param(
|
||||
[AllowNull()][string]$Existing,
|
||||
[Parameter(Mandatory = $true)][string]$PathToAdd
|
||||
)
|
||||
|
||||
$normalizedAdd = $PathToAdd.Trim().TrimEnd('\')
|
||||
$items = @()
|
||||
|
||||
if ($Existing) {
|
||||
$items = $Existing.Split(';') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
}
|
||||
|
||||
if (-not $items -or $items.Count -eq 0) {
|
||||
# 保留 AutoCAD 常见默认值,避免误删用户原本设置
|
||||
$items = @("APP_PATH")
|
||||
}
|
||||
|
||||
$exists = $false
|
||||
foreach ($it in $items) {
|
||||
if ($it.Trim().TrimEnd('\').Equals($normalizedAdd, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$exists = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $exists) {
|
||||
$items += $PathToAdd
|
||||
}
|
||||
|
||||
# 去重(忽略大小写 + 末尾反斜杠)
|
||||
$seen = New-Object "System.Collections.Generic.HashSet[string]" ([StringComparer]::OrdinalIgnoreCase)
|
||||
$dedup = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($it in $items) {
|
||||
$k = $it.Trim().TrimEnd('\')
|
||||
if ($seen.Add($k)) {
|
||||
$dedup.Add($it.Trim())
|
||||
}
|
||||
}
|
||||
|
||||
return ($dedup -join ";")
|
||||
}
|
||||
|
||||
Write-Step "[0/3] 环境检查..."
|
||||
|
||||
if (Get-Process acad -ErrorAction SilentlyContinue) {
|
||||
Write-Host ""
|
||||
Write-Host "注意:检测到 AutoCAD 正在运行 (acad.exe)。" -ForegroundColor Yellow
|
||||
Write-Host "为避免 DLL 被占用导致复制/删除失败,建议先完全关闭 AutoCAD 再安装。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if (Test-IsAdmin) {
|
||||
Write-Host ""
|
||||
Write-Host "注意:你正在以管理员身份运行。" -ForegroundColor Yellow
|
||||
Write-Host "本插件按“当前 Windows 用户”安装(%AppData% + HKCU 注册表)。" -ForegroundColor Yellow
|
||||
Write-Host "如果不是在设计师账号下运行,安装后可能在设计师账号下不生效。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$bundleSrc = Join-Path $scriptDir "FurnitureExporter.bundle"
|
||||
|
||||
# 兼容“解压覆盖旧目录”导致的套娃结构:FurnitureExporter.bundle\FurnitureExporter.bundle\...
|
||||
if (-not (Test-Path (Join-Path $bundleSrc "PackageContents.xml"))) {
|
||||
$nested = Join-Path $bundleSrc "FurnitureExporter.bundle"
|
||||
if (Test-Path (Join-Path $nested "PackageContents.xml")) {
|
||||
$bundleSrc = $nested
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path (Join-Path $bundleSrc "PackageContents.xml"))) {
|
||||
Write-Host "未找到 FurnitureExporter.bundle(请先解压安装包,再运行 安装.bat)。" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
$appPlugins = Join-Path $env:APPDATA "Autodesk\ApplicationPlugins"
|
||||
$bundleDst = Join-Path $appPlugins "FurnitureExporter.bundle"
|
||||
$dllPath = Join-Path $bundleDst "Contents\FurnitureExporter.dll"
|
||||
$trustedDir = Join-Path $bundleDst "Contents"
|
||||
|
||||
Write-Step "[1/3] 复制插件..."
|
||||
New-Item -ItemType Directory -Path $appPlugins -Force | Out-Null
|
||||
|
||||
if (Test-Path $bundleDst) {
|
||||
try {
|
||||
Remove-Item -Path $bundleDst -Recurse -Force
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Host "删除旧版本失败:$bundleDst" -ForegroundColor Red
|
||||
Write-Host "常见原因:AutoCAD 未完全关闭/文件被占用/安全软件拦截。" -ForegroundColor Yellow
|
||||
Write-Host "请关闭 AutoCAD 后重试,或手动删除上述目录。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Copy-Item -Path $bundleSrc -Destination $bundleDst -Recurse -Force
|
||||
|
||||
if (-not (Test-Path $dllPath)) {
|
||||
Write-Host "安装失败:未找到 DLL:$dllPath" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Step "[2/3] 注册自启动/可信路径..."
|
||||
|
||||
$acadRoot = "HKCU:\Software\Autodesk\AutoCAD\R24.3"
|
||||
$curVerObj = Get-ItemProperty -Path $acadRoot -Name CurVer -ErrorAction SilentlyContinue
|
||||
$acadCur = $curVerObj.CurVer
|
||||
|
||||
if (-not $acadCur) {
|
||||
Write-Host "未检测到 AutoCAD 2024 注册表信息:$acadRoot\CurVer" -ForegroundColor Yellow
|
||||
Write-Host "已完成文件复制,但未写入自启动注册。请确认已至少启动过 AutoCAD 2024 一次。" -ForegroundColor Yellow
|
||||
}
|
||||
else {
|
||||
$acadBase = Join-Path $acadRoot $acadCur
|
||||
|
||||
# 1) Applications 自启动(.NET 插件标准方式)
|
||||
$appKey = Join-Path $acadBase "Applications\FurnitureExporter"
|
||||
New-Item -Path $appKey -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "DESCRIPTION" -PropertyType String -Value "家具导出插件 v$Version" -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "LOADCTRLS" -PropertyType DWord -Value 2 -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "MANAGED" -PropertyType DWord -Value 1 -Force | Out-Null
|
||||
New-ItemProperty -Path $appKey -Name "LOADER" -PropertyType String -Value $dllPath -Force | Out-Null
|
||||
|
||||
# 2) 可信路径:只加入本插件 Contents,避免把整个 ApplicationPlugins 目录设为可信
|
||||
$profilesRoot = Join-Path $acadBase "Profiles"
|
||||
$profiles = Get-ChildItem -Path $profilesRoot -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($p in $profiles) {
|
||||
$varsKey = Join-Path $p.PSPath "Variables"
|
||||
if (-not (Test-Path $varsKey)) { continue }
|
||||
|
||||
$vars = Get-ItemProperty -Path $varsKey -ErrorAction SilentlyContinue
|
||||
$existingTrusted = $null
|
||||
if ($vars.PSObject.Properties.Name -contains "TRUSTEDPATHS") {
|
||||
$existingTrusted = [string]$vars.TRUSTEDPATHS
|
||||
}
|
||||
|
||||
$newTrusted = Add-TrustedPath -Existing $existingTrusted -PathToAdd $trustedDir
|
||||
if ($existingTrusted -ne $newTrusted) {
|
||||
New-ItemProperty -Path $varsKey -Name "TRUSTEDPATHS" -PropertyType String -Value $newTrusted -Force | Out-Null
|
||||
}
|
||||
|
||||
# 如果 SECURELOAD=2,会导致即使加入可信路径也不加载(尽量只从 2 降到 1)
|
||||
if ($vars.PSObject.Properties.Name -contains "SECURELOAD") {
|
||||
if ([string]$vars.SECURELOAD -eq "2") {
|
||||
New-ItemProperty -Path $varsKey -Name "SECURELOAD" -PropertyType String -Value "1" -Force | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "[3/3] 完成"
|
||||
Write-Host ""
|
||||
Write-Host "安装完成。请完全关闭 AutoCAD 后重新启动。" -ForegroundColor Green
|
||||
Write-Host "启动后命令行输入:FEXPORT(单资产)或 FEXPORTPRODUCT(整套兼容)" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
@ -1,106 +0,0 @@
|
||||
# 家具导出插件 - 一键卸载脚本(PowerShell)
|
||||
# 目标:删除当前用户 ApplicationPlugins 下的 bundle,并清理自启动注册/可信路径。
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Text)
|
||||
Write-Host $Text -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Remove-TrustedPath {
|
||||
param(
|
||||
[AllowNull()][string]$Existing,
|
||||
[Parameter(Mandatory = $true)][string]$PathToRemove
|
||||
)
|
||||
|
||||
if (-not $Existing) { return $Existing }
|
||||
|
||||
$normalizedRemove = $PathToRemove.Trim().TrimEnd('\')
|
||||
$items = $Existing.Split(';') | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
|
||||
$kept = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($it in $items) {
|
||||
$k = $it.Trim().TrimEnd('\')
|
||||
if (-not $k.Equals($normalizedRemove, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
$kept.Add($it.Trim())
|
||||
}
|
||||
}
|
||||
|
||||
return ($kept -join ";")
|
||||
}
|
||||
|
||||
Write-Step "[0/3] 环境检查..."
|
||||
|
||||
if (Get-Process acad -ErrorAction SilentlyContinue) {
|
||||
Write-Host ""
|
||||
Write-Host "注意:检测到 AutoCAD 正在运行 (acad.exe)。" -ForegroundColor Yellow
|
||||
Write-Host "如卸载失败,请先完全关闭 AutoCAD 后再重试。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$appPlugins = Join-Path $env:APPDATA "Autodesk\ApplicationPlugins"
|
||||
$bundleDst = Join-Path $appPlugins "FurnitureExporter.bundle"
|
||||
$trustedDir = Join-Path $bundleDst "Contents"
|
||||
|
||||
Write-Step "[1/3] 删除插件文件..."
|
||||
if (Test-Path $bundleDst) {
|
||||
try {
|
||||
Remove-Item -Path $bundleDst -Recurse -Force
|
||||
}
|
||||
catch {
|
||||
Write-Host ""
|
||||
Write-Host "删除失败:$bundleDst" -ForegroundColor Red
|
||||
Write-Host "常见原因:AutoCAD 未完全关闭/文件被占用/安全软件拦截。" -ForegroundColor Yellow
|
||||
Write-Host "你也可以手动删除该目录后,再运行 卸载.bat 清理注册表。" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "[2/3] 清理注册表..."
|
||||
|
||||
$acadRoot = "HKCU:\Software\Autodesk\AutoCAD\R24.3"
|
||||
$curVerObj = Get-ItemProperty -Path $acadRoot -Name CurVer -ErrorAction SilentlyContinue
|
||||
$acadCur = $curVerObj.CurVer
|
||||
|
||||
if ($acadCur) {
|
||||
$acadBase = Join-Path $acadRoot $acadCur
|
||||
|
||||
# Applications 自启动项
|
||||
$appKey = Join-Path $acadBase "Applications\FurnitureExporter"
|
||||
if (Test-Path $appKey) {
|
||||
Remove-Item -Path $appKey -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# TRUSTEDPATHS 移除插件 Contents(只移除我们加的这一项,避免影响其他插件)
|
||||
$profilesRoot = Join-Path $acadBase "Profiles"
|
||||
$profiles = Get-ChildItem -Path $profilesRoot -ErrorAction SilentlyContinue
|
||||
foreach ($p in $profiles) {
|
||||
$varsKey = Join-Path $p.PSPath "Variables"
|
||||
if (-not (Test-Path $varsKey)) { continue }
|
||||
|
||||
$vars = Get-ItemProperty -Path $varsKey -ErrorAction SilentlyContinue
|
||||
if (-not ($vars.PSObject.Properties.Name -contains "TRUSTEDPATHS")) { continue }
|
||||
|
||||
$existingTrusted = [string]$vars.TRUSTEDPATHS
|
||||
$newTrusted = Remove-TrustedPath -Existing $existingTrusted -PathToRemove $trustedDir
|
||||
|
||||
if ($existingTrusted -ne $newTrusted) {
|
||||
if ([string]::IsNullOrWhiteSpace($newTrusted)) {
|
||||
Remove-ItemProperty -Path $varsKey -Name "TRUSTEDPATHS" -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
New-ItemProperty -Path $varsKey -Name "TRUSTEDPATHS" -PropertyType String -Value $newTrusted -Force | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "未检测到 AutoCAD 2024 注册表信息,跳过注册表清理。" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Step "[3/3] 完成"
|
||||
Write-Host ""
|
||||
Write-Host "卸载完成。请完全关闭 AutoCAD 后重新启动。" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
@ -1,42 +0,0 @@
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
家具导出插件 v2.6.0 - 安装指南
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
安装步骤(只需30秒)
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
1) 解压安装包
|
||||
2) 双击运行:安装.bat
|
||||
3) 完全关闭 AutoCAD 2024 后重新启动
|
||||
|
||||
卸载步骤(只需10秒)
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
1) 双击运行:卸载.bat
|
||||
2) 完全关闭 AutoCAD 2024 后重新启动
|
||||
|
||||
使用方法
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
打开 AutoCAD 后,命令行输入:
|
||||
FEXPORT -> 单资产导出到资产中心(板材 / 组件 / 五金)
|
||||
FEXPORTASSET -> 单资产导出别名
|
||||
FEXPORTPRODUCT -> 整套家具兼容导出
|
||||
FEXPORTBUNDLE -> 整套家具兼容导出别名
|
||||
FINFO -> 查看实体信息
|
||||
|
||||
安装脚本做了什么(便于 IT/排查)
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
1) 复制到:
|
||||
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle
|
||||
2) 注册自启动(HKCU):
|
||||
HKCU\Software\Autodesk\AutoCAD\R24.3\<CurVer>\Applications\FurnitureExporter
|
||||
3) 追加可信路径(HKCU / 每个 Profile):
|
||||
TRUSTEDPATHS += ...\FurnitureExporter.bundle\Contents
|
||||
|
||||
常见问题
|
||||
───────────────────────────────────────────────────────────────────────
|
||||
Q: 提示“未知命令 FEXPORT / FEXPORTPRODUCT”
|
||||
A: 1) 确认已重启 AutoCAD(关闭再打开)
|
||||
2) 确认插件目录存在:
|
||||
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle\Contents\FurnitureExporter.dll
|
||||
3) 如果 SECURELOAD=2,脚本会将其降到 1;如仍无效请截图安装日志给开发排查
|
||||
|
||||
版本: v2.6.0
|
||||
@ -1,15 +0,0 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
title 家具导出插件 卸载程序
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if not exist "%SCRIPT_DIR%uninstall.ps1" (
|
||||
echo 未找到 uninstall.ps1
|
||||
echo 请先解压安装包到当前目录后再运行。
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%uninstall.ps1"
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
@ -1,15 +0,0 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
title 家具导出插件 安装程序
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
if not exist "%SCRIPT_DIR%install.ps1" (
|
||||
echo 未找到 install.ps1
|
||||
echo 请先解压安装包到当前目录后再运行。
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%install.ps1"
|
||||
echo.
|
||||
pause
|
||||
endlocal
|
||||
@ -59,13 +59,10 @@ docker-compose -f docker-compose.lightweight.yml up -d
|
||||
### 4. 初始化数据库
|
||||
```bash
|
||||
# 进入应用容器执行迁移
|
||||
docker exec -it furniture3d-app npx prisma migrate deploy
|
||||
docker exec -it furniture3d-app npx prisma db push
|
||||
|
||||
# 或在本地执行(需要数据库可访问)
|
||||
npx prisma migrate deploy
|
||||
|
||||
# 迁移后验证主数据地基
|
||||
npm run db:verify-master-data:all:strict
|
||||
npx prisma db push
|
||||
```
|
||||
|
||||
### 5. 访问系统
|
||||
@ -137,22 +134,19 @@ JWT_EXPIRES_IN=7d
|
||||
|
||||
### 首次部署
|
||||
```bash
|
||||
# 应用 Prisma migration,保留 _prisma_migrations 历史
|
||||
npx prisma migrate deploy
|
||||
# 推送 schema 到数据库
|
||||
npx prisma db push
|
||||
|
||||
# 生成 Prisma Client
|
||||
npx prisma generate
|
||||
|
||||
# 验证主数据迁移、视图、约束和 JSON 边界
|
||||
npm run db:verify-master-data:all:strict
|
||||
```
|
||||
|
||||
### 后续更新(Schema 变更)
|
||||
```bash
|
||||
# 仅本地临时开发环境可用;脚本会拒绝 production、非本机库和疑似生产库名
|
||||
npm run prisma:push
|
||||
# 开发环境:直接推送
|
||||
npx prisma db push
|
||||
|
||||
# 生产环境:必须使用迁移
|
||||
# 生产环境:使用迁移(推荐)
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
@ -278,13 +272,10 @@ docker-compose -f docker-compose.prod.yml build app
|
||||
docker-compose -f docker-compose.prod.yml up -d app
|
||||
|
||||
# 执行数据库迁移(如有)
|
||||
docker exec -it furniture3d-app npx prisma migrate deploy
|
||||
docker exec -it furniture3d-app npm run db:verify-master-data:all:strict
|
||||
docker exec -it furniture3d-app npx prisma db push
|
||||
```
|
||||
|
||||
### 重要提示
|
||||
- 更新前请先备份数据库
|
||||
- 测试环境验证后再更新生产环境
|
||||
- 保留旧版本镜像以便回滚
|
||||
- 生产/准生产禁止用 `prisma db push` 替代迁移;否则会绕过 `_prisma_migrations`,后续无法证明数据库结构来源。
|
||||
- 仓库内 `npm run prisma:push` 只作为本地开发保护入口:默认只允许 `localhost / 127.0.0.1 / ::1`,拒绝 `NODE_ENV=production`、`VERCEL_ENV=production` 和疑似生产库名。
|
||||
522
docs/PHASES_8_9_SUMMARY.md
Normal file
522
docs/PHASES_8_9_SUMMARY.md
Normal file
@ -0,0 +1,522 @@
|
||||
# Phase 8-9 实施总结
|
||||
|
||||
## 📋 项目概述
|
||||
|
||||
本文档总结了 Phase 8(数据安全与日志)和 Phase 9(测试与优化)的实施情况。
|
||||
|
||||
**完成时间**:2025-12-12
|
||||
**项目状态**:Phase 8 完成,Phase 9 规划完成
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 8:数据安全与日志(已完成)
|
||||
|
||||
### 核心成果
|
||||
|
||||
成功实现了完整的操作日志系统和审计报表功能,为系统提供了全面的审计追踪能力。
|
||||
|
||||
### 已完成的功能
|
||||
|
||||
#### 1. 日志查询系统
|
||||
- ✅ **日志查询 API** (`GET /api/logs`)
|
||||
- 支持多维度查询(时间、用户、操作类型、实体类型)
|
||||
- 实现权限隔离(部门管理员只能查看本部门)
|
||||
- 分页功能
|
||||
- 响应时间优化
|
||||
|
||||
- ✅ **日志导出 API** (`POST /api/logs/export`)
|
||||
- CSV 格式导出
|
||||
- JSON 格式导出
|
||||
- 自动过滤敏感信息
|
||||
- 限制导出数量(10000 条)
|
||||
- 记录导出操作
|
||||
|
||||
#### 2. 审计报表系统
|
||||
- ✅ **用户活动报表** (`GET /api/reports/user-activity`)
|
||||
- 登录次数统计
|
||||
- 操作次数统计
|
||||
- 活跃用户分析
|
||||
- 按活跃度排序
|
||||
|
||||
- ✅ **产品变更报表** (`GET /api/reports/product-changes`)
|
||||
- 按日期分组统计
|
||||
- 创建/更新/删除分类
|
||||
- 趋势分析(最近 30 天)
|
||||
|
||||
- ✅ **SKU 交接报表** (`GET /api/reports/sku-transfers`)
|
||||
- 交接记录详情
|
||||
- 按部门统计
|
||||
- 交接历史追踪
|
||||
|
||||
- ✅ **反馈处理报表** (`GET /api/reports/feedback-stats`)
|
||||
- 平均处理时长
|
||||
- 按优先级统计
|
||||
- 按状态统计
|
||||
- 每日趋势分析
|
||||
|
||||
#### 3. 日志记录工具
|
||||
- ✅ **统一日志接口** (`src/lib/logger.ts`)
|
||||
- 自动过滤敏感信息
|
||||
- 便捷的日志记录函数
|
||||
- 元数据提取(IP、User Agent)
|
||||
- 支持所有操作类型
|
||||
|
||||
### 技术亮点
|
||||
|
||||
1. **完整的权限控制**
|
||||
- 基于角色的访问控制
|
||||
- 数据访问隔离
|
||||
- 部门级别的数据过滤
|
||||
|
||||
2. **性能优化**
|
||||
- 数据库索引优化
|
||||
- 使用 `groupBy` 聚合查询
|
||||
- 原始 SQL 优化复杂查询
|
||||
- 查询结果限制
|
||||
|
||||
3. **安全措施**
|
||||
- 敏感信息自动过滤
|
||||
- 操作审计追踪
|
||||
- 权限严格检查
|
||||
|
||||
### 交付物清单
|
||||
|
||||
**API 端点**(6 个):
|
||||
1. `src/app/api/logs/route.ts`
|
||||
2. `src/app/api/logs/export/route.ts`
|
||||
3. `src/app/api/reports/user-activity/route.ts`
|
||||
4. `src/app/api/reports/product-changes/route.ts`
|
||||
5. `src/app/api/reports/sku-transfers/route.ts`
|
||||
6. `src/app/api/reports/feedback-stats/route.ts`
|
||||
|
||||
**工具函数**(1 个):
|
||||
1. `src/lib/logger.ts`
|
||||
|
||||
**文档**(2 个):
|
||||
1. `docs/PHASE8_COMPLETE.md`
|
||||
2. `docs/PHASES_8_9_SUMMARY.md`(本文档)
|
||||
|
||||
### 代码统计
|
||||
- **新增文件**:8 个
|
||||
- **新增代码行数**:约 1500 行
|
||||
- **API 端点**:6 个
|
||||
- **代码通过类型检查**:✅
|
||||
|
||||
---
|
||||
|
||||
## 📝 Phase 9:测试与优化(进行中)
|
||||
|
||||
### 实施概述
|
||||
|
||||
Phase 9 已经开始实施,目前完成了前端性能优化和安全测试。
|
||||
|
||||
### 规划文档
|
||||
|
||||
1. **需求文档** (`../../.kiro/specs/历史归档/Phase9-测试与优化/requirements.md`)
|
||||
- 功能测试需求
|
||||
- 性能优化需求
|
||||
- 安全测试需求
|
||||
- 用户体验优化需求
|
||||
|
||||
2. **设计文档** (`../../.kiro/specs/历史归档/Phase9-测试与优化/design.md`)
|
||||
- 测试策略(单元测试、集成测试、E2E 测试)
|
||||
- 性能优化方案
|
||||
- 安全加固措施
|
||||
- 用户体验改进方案
|
||||
|
||||
3. **任务清单** (`../../.kiro/specs/历史归档/Phase9-测试与优化/tasks.md`)
|
||||
- 7 天详细任务分解
|
||||
- 每日任务清单
|
||||
- 验收标准
|
||||
|
||||
### 已完成的任务
|
||||
|
||||
#### ✅ 第 4 天:前端性能优化(2025-12-13)
|
||||
|
||||
**上午:代码分割和懒加载**
|
||||
- ✅ 创建性能优化工具库 (`src/lib/performance.ts`)
|
||||
- 防抖、节流函数
|
||||
- 懒加载、预加载工具
|
||||
- 动态导入辅助函数
|
||||
- ✅ 创建懒加载包装组件 (`src/components/common/LazyLoadWrapper.tsx`)
|
||||
- ✅ 创建懒加载组件配置 (`src/lib/lazy-components.ts`)
|
||||
- ✅ 创建性能监控 Hooks (`src/hooks/usePerformance.ts`)
|
||||
- ✅ 创建图片懒加载组件 (`src/components/common/LazyImage.tsx`)
|
||||
|
||||
**下午:渲染性能优化**
|
||||
- ✅ 优化 `HomeClient.tsx`
|
||||
- 添加搜索功能
|
||||
- 使用 useMemo、useCallback、useDebounce
|
||||
- ✅ 优化 `UsersPage.tsx`
|
||||
- 优化 13 个回调函数
|
||||
- 使用防抖搜索
|
||||
- ✅ 优化 `UserListItem.tsx`
|
||||
- 使用 memo、useMemo、useCallback
|
||||
- ✅ 优化 `ProductListItem.tsx`
|
||||
- 使用 memo、useMemo、useCallback
|
||||
- ✅ 创建性能测试 (`tests/performance/rendering.test.tsx`)
|
||||
- 12 个测试用例,100% 通过
|
||||
|
||||
**性能提升**:
|
||||
- 渲染 100 个用户列表项:259ms
|
||||
- 渲染 50 个产品列表项:98ms
|
||||
- 首屏加载时间减少 30-40%
|
||||
- 路由切换时间减少 50%
|
||||
|
||||
**代码量**:约 1320 行(660 行工具 + 660 行优化)
|
||||
|
||||
---
|
||||
|
||||
#### ✅ 第 5 天下午:安全测试(2025-12-13)
|
||||
|
||||
**依赖漏洞扫描**
|
||||
- ✅ 运行 `npm audit`
|
||||
- ✅ 检查结果:0 个实际影响的漏洞
|
||||
- ✅ Next.js 15.1.3 不在受影响范围内
|
||||
|
||||
**SQL 注入测试**
|
||||
- ✅ 创建测试文件 (`tests/security/sql-injection.test.ts`)
|
||||
- ✅ 17 个测试用例
|
||||
- ✅ 测试覆盖:用户查询、产品搜索、复杂查询、批量操作
|
||||
- ✅ 结论:Prisma ORM 提供完善的 SQL 注入防护
|
||||
|
||||
**XSS 测试**
|
||||
- ✅ 创建测试文件 (`tests/security/xss.test.ts`)
|
||||
- ✅ 14 个测试用例
|
||||
- ✅ 测试覆盖:React 转义、URL 参数、表单输入、CSS 注入、SVG XSS
|
||||
- ✅ 结论:React 自动转义机制完善
|
||||
|
||||
**权限绕过测试**
|
||||
- ✅ 创建测试文件 (`tests/security/authorization.test.ts`)
|
||||
- ✅ 33 个测试用例
|
||||
- ✅ 测试覆盖:未授权访问、产品权限、用户管理、横向越权、纵向越权
|
||||
- ✅ 结论:权限系统设计完善
|
||||
|
||||
**安全测试报告**
|
||||
- ✅ 创建文档 (`docs/安全测试报告.md`)
|
||||
- ✅ 总体安全评分:⭐⭐⭐⭐⭐ (5/5)
|
||||
|
||||
**测试统计**:
|
||||
- 测试用例:64 个
|
||||
- 测试代码:约 800 行
|
||||
- 文档:约 600 行
|
||||
- 总计:约 1400 行
|
||||
|
||||
---
|
||||
|
||||
### 待完成的任务
|
||||
|
||||
#### ⏳ 第 1-3 天:测试框架和核心测试(可选)
|
||||
- 安装测试依赖(Jest, Testing Library, Supertest)
|
||||
- 配置测试环境
|
||||
- 权限系统单元测试
|
||||
- API 集成测试
|
||||
|
||||
#### ⏳ 第 5 天上午:3D 性能优化(可选)
|
||||
- 模型压缩(Draco)
|
||||
- LOD(细节层次)
|
||||
- 纹理优化
|
||||
- 渲染器配置优化
|
||||
|
||||
#### ⏳ 第 6 天:用户体验优化(可选)
|
||||
- 界面优化
|
||||
- 交互优化
|
||||
- 响应式设计
|
||||
- 无障碍支持
|
||||
|
||||
#### ⏳ 第 7 天:E2E 测试和文档(可选)
|
||||
- E2E 测试
|
||||
- 文档完善
|
||||
- 最终验收
|
||||
|
||||
### 性能目标
|
||||
|
||||
| 指标 | 目标值 | 当前状态 | 达成情况 |
|
||||
|------|--------|---------|---------|
|
||||
| API 响应时间 | < 200ms | 待测试 | ⏳ |
|
||||
| 页面加载时间 | < 2s | 减少 30-40% | ✅ |
|
||||
| 路由切换时间 | < 500ms | 减少 50% | ✅ |
|
||||
| 列表渲染 | 流畅 | 100 项 259ms | ✅ |
|
||||
| 3D 模型加载 | < 3s | 待测试 | ⏳ |
|
||||
| 动画帧率 | 60fps | 待测试 | ⏳ |
|
||||
| 内存使用 | < 500MB | 待测试 | ⏳ |
|
||||
| 测试覆盖率 | > 80% | 安全测试 100% | 🟡 |
|
||||
|
||||
### 安全目标
|
||||
|
||||
- ✅ 无高危安全漏洞(已验证)
|
||||
- ✅ 无中危安全漏洞(已验证)
|
||||
- ✅ 低危漏洞 < 5 个(0 个)
|
||||
- ✅ SQL 注入防护(17 个测试通过)
|
||||
- ✅ XSS 防护(14 个测试通过)
|
||||
- ✅ 权限控制(33 个测试通过)
|
||||
- ✅ 总体安全评分:⭐⭐⭐⭐⭐ (5/5)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 整体项目进度
|
||||
|
||||
### 已完成的阶段
|
||||
|
||||
| 阶段 | 状态 | 完成时间 | 主要成果 |
|
||||
|------|------|---------|---------|
|
||||
| Phase 1 | ✅ 完成 | Week 1 | 数据库模型重构 |
|
||||
| Phase 2 | ✅ 完成 | Week 2-3 | 认证系统实现 |
|
||||
| Phase 3 | ✅ 完成 | Week 4-5 | 权限系统实现 |
|
||||
| Phase 4 | ✅ 完成 | Week 6 | 组织架构实现 |
|
||||
| Phase 5 | ✅ 完成 | Week 7-8 | 产品归属实现 |
|
||||
| Phase 6 | ✅ 完成 | Week 9-10 | 离职交接实现 |
|
||||
| Phase 7 | ✅ 完成 | Week 11 | 问题反馈实现 |
|
||||
| Phase 8 | ✅ 完成 | Week 12 | 数据安全与日志 |
|
||||
| Phase 9 | 🚧 进行中 | Week 13 | 测试与优化(Day 4-5 完成)|
|
||||
|
||||
### 功能完成度
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 用户认证系统(100%)
|
||||
- ✅ 权限管理系统(100%)
|
||||
- ✅ 组织架构管理(100%)
|
||||
- ✅ 产品管理系统(100%)
|
||||
- ✅ SKU 管理系统(100%)
|
||||
- ✅ 离职交接系统(100%)
|
||||
- ✅ 问题反馈系统(100%)
|
||||
- ✅ 操作日志系统(100%)
|
||||
- ✅ 审计报表系统(100%)
|
||||
|
||||
**Phase 9 进展**:
|
||||
- ✅ 前端性能优化(100%)
|
||||
- ✅ 安全测试(100%)
|
||||
- ⏳ 3D 性能优化(0%)
|
||||
- ⏳ 用户体验优化(0%)
|
||||
- ⏳ E2E 测试(0%)
|
||||
|
||||
### 代码统计
|
||||
|
||||
**总体统计**:
|
||||
- **总文件数**:约 50+ 个
|
||||
- **总代码行数**:约 8000+ 行
|
||||
- **API 端点数**:约 30+ 个
|
||||
- **数据库模型**:11 个
|
||||
|
||||
**Phase 8 贡献**:
|
||||
- **新增文件**:8 个
|
||||
- **新增代码**:约 1500 行
|
||||
- **新增 API**:6 个
|
||||
|
||||
**Phase 9 贡献(Day 4-5)**:
|
||||
- **新增文件**:13 个
|
||||
- **新增代码**:约 2720 行
|
||||
- 性能优化工具:约 660 行
|
||||
- 组件优化:约 660 行
|
||||
- 安全测试:约 800 行
|
||||
- 文档:约 600 行
|
||||
- **测试用例**:76 个(12 个性能 + 64 个安全)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 下一步建议
|
||||
|
||||
### 立即可以做的事情
|
||||
|
||||
1. **运行类型检查**
|
||||
```bash
|
||||
cd furniture-3d-system
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
2. **启动开发服务器测试**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **测试 API 端点**
|
||||
- 使用 Postman 或 curl 测试新的日志和报表 API
|
||||
- 验证权限控制是否正确
|
||||
- 检查响应格式是否符合预期
|
||||
|
||||
### Phase 9 实施建议
|
||||
|
||||
#### 选项 1:完整实施(推荐)
|
||||
按照规划文档完整实施 Phase 9,包括:
|
||||
- 搭建测试框架
|
||||
- 编写单元测试和集成测试
|
||||
- 进行性能优化
|
||||
- 进行安全测试
|
||||
- 优化用户体验
|
||||
|
||||
**预计时间**:5-7 天
|
||||
**收益**:系统质量有保障,可以正式投入使用
|
||||
|
||||
#### 选项 2:最小化实施
|
||||
只实施最关键的部分:
|
||||
- 核心功能的集成测试
|
||||
- 基本的性能优化
|
||||
- 关键的安全检查
|
||||
|
||||
**预计时间**:2-3 天
|
||||
**收益**:快速验证核心功能,尽早发现问题
|
||||
|
||||
#### 选项 3:渐进式实施
|
||||
边使用边优化:
|
||||
- 先部署到测试环境
|
||||
- 收集实际使用反馈
|
||||
- 根据反馈逐步优化
|
||||
|
||||
**预计时间**:持续进行
|
||||
**收益**:基于实际需求优化,避免过度工程
|
||||
|
||||
### 技术债务管理
|
||||
|
||||
**已知的技术债务**:
|
||||
1. 部分旧代码有类型错误(不影响 Phase 8-9)
|
||||
2. 缺少自动化测试
|
||||
3. 性能未经过压力测试
|
||||
4. 安全性未经过专业审计
|
||||
|
||||
**建议优先级**:
|
||||
1. 🔴 高优先级:核心功能测试
|
||||
2. 🟡 中优先级:性能优化
|
||||
3. 🟢 低优先级:用户体验优化
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文档
|
||||
|
||||
### Phase 8 文档
|
||||
- [Phase 8 完成文档](./PHASE8_COMPLETE.md)
|
||||
- [Phase 8 需求文档](../.kiro/specs/data-security-logging/requirements.md)
|
||||
- [Phase 8 设计文档](../.kiro/specs/data-security-logging/design.md)
|
||||
- [Phase 8 任务清单](../.kiro/specs/data-security-logging/tasks.md)
|
||||
|
||||
### Phase 9 文档
|
||||
- [Phase 9 需求文档](../../.kiro/specs/历史归档/Phase9-测试与优化/requirements.md)
|
||||
- [Phase 9 设计文档](../../.kiro/specs/历史归档/Phase9-测试与优化/design.md)
|
||||
- [Phase 9 任务清单](../../.kiro/specs/历史归档/Phase9-测试与优化/tasks.md)
|
||||
|
||||
### 总览文档
|
||||
- [Phase 8-9 规划总览](../../.kiro/specs/历史归档/PHASES_8_9_OVERVIEW.md)
|
||||
- [项目总览](./项目总览.md)
|
||||
- [文档导航](./README.md)
|
||||
|
||||
### 历史文档
|
||||
- [Phase 1-7 完成总结](./PHASES_5_6_7_SUMMARY.md)
|
||||
- [Phase 1 完成文档](./PHASE1_COMPLETE.md)
|
||||
- [Phase 2 完成文档](./PHASE2_COMPLETE.md)
|
||||
- [Phase 3 完成文档](./PHASE3_COMPLETE.md)
|
||||
- [Phase 4 完成文档](./PHASE4_COMPLETE.md)
|
||||
- [Phase 5 完成文档](./PHASE5_COMPLETE.md)
|
||||
- [Phase 6 完成文档](./PHASE6_COMPLETE.md)
|
||||
- [Phase 7 完成文档](./PHASE7_COMPLETE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 成就总结
|
||||
|
||||
### Phase 8 成就
|
||||
|
||||
1. **完整的审计系统**
|
||||
- 6 个 API 端点
|
||||
- 全面的日志记录
|
||||
- 多维度的报表分析
|
||||
|
||||
2. **企业级功能**
|
||||
- 权限隔离
|
||||
- 数据安全
|
||||
- 操作追踪
|
||||
|
||||
3. **高质量代码**
|
||||
- 类型安全
|
||||
- 错误处理完善
|
||||
- 代码结构清晰
|
||||
|
||||
### 项目整体成就
|
||||
|
||||
1. **功能完整性**
|
||||
- ✅ 9 个核心模块全部完成
|
||||
- ✅ 30+ 个 API 端点
|
||||
- ✅ 完整的权限体系
|
||||
|
||||
2. **技术栈现代化**
|
||||
- Next.js 14 + React 18
|
||||
- TypeScript 严格模式
|
||||
- Prisma ORM
|
||||
- PostgreSQL
|
||||
|
||||
3. **企业级特性**
|
||||
- 6 种用户角色
|
||||
- 部门/小组管理
|
||||
- 完整的审计追踪
|
||||
- 数据安全保护
|
||||
|
||||
---
|
||||
|
||||
## 💡 经验总结
|
||||
|
||||
### 成功经验
|
||||
|
||||
1. **系统化的规划**
|
||||
- 详细的 spec 文档
|
||||
- 清晰的任务分解
|
||||
- 明确的验收标准
|
||||
|
||||
2. **渐进式开发**
|
||||
- 按阶段实施
|
||||
- 每个阶段都有明确的目标
|
||||
- 及时记录和总结
|
||||
|
||||
3. **代码质量保障**
|
||||
- TypeScript 类型检查
|
||||
- 统一的代码风格
|
||||
- 完善的错误处理
|
||||
|
||||
### 改进建议
|
||||
|
||||
1. **测试驱动开发**
|
||||
- 应该更早引入测试
|
||||
- 边开发边测试
|
||||
- 自动化测试覆盖
|
||||
|
||||
2. **性能监控**
|
||||
- 应该更早关注性能
|
||||
- 建立性能基准
|
||||
- 持续监控优化
|
||||
|
||||
3. **文档先行**
|
||||
- API 文档应该更详细
|
||||
- 使用示例应该更丰富
|
||||
- 部署文档应该更完善
|
||||
|
||||
---
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
### 如果遇到问题
|
||||
|
||||
1. **查看文档**
|
||||
- 查看相关的设计文档
|
||||
- 查看 API 文档
|
||||
- 查看代码注释
|
||||
|
||||
2. **运行测试**
|
||||
- 运行类型检查
|
||||
- 测试 API 端点
|
||||
- 查看错误日志
|
||||
|
||||
3. **参考示例**
|
||||
- 查看已完成的代码
|
||||
- 参考类似的实现
|
||||
- 查看测试用例
|
||||
|
||||
---
|
||||
|
||||
**Phase 8 完成,Phase 9 规划完成!** 🎉
|
||||
|
||||
系统现在具备了完整的核心功能和审计能力。接下来可以根据实际需求选择合适的方式实施 Phase 9,进一步提升系统质量。
|
||||
|
||||
**预计完成时间**:如果完整实施 Phase 9,预计 2025-12-19 完成
|
||||
**当前状态**:系统核心功能已完成,可以开始测试和优化
|
||||
|
||||
---
|
||||
|
||||
**创建时间**:2025-12-12
|
||||
**最后更新**:2025-12-12
|
||||
181
docs/Phase10完成报告.md
Normal file
181
docs/Phase10完成报告.md
Normal file
@ -0,0 +1,181 @@
|
||||
# Phase 10: 生产就绪 - 完成报告
|
||||
|
||||
> **完成日期**:2025-12-22
|
||||
> **状态**:✅ 已完成
|
||||
|
||||
---
|
||||
|
||||
## 📋 完成概览
|
||||
|
||||
Phase 10 生产就绪阶段已全部完成,系统现已具备完整的生产环境部署能力。
|
||||
|
||||
### 主要成果
|
||||
|
||||
| 功能模块 | 状态 | 说明 |
|
||||
|---------|------|------|
|
||||
| 数据库备份 | ✅ 完成 | 支持本地和 Docker 环境 |
|
||||
| 文件存储备份 | ✅ 完成 | 支持 MinIO Docker 容器 |
|
||||
| 完整系统备份 | ✅ 完成 | 一键备份所有数据 |
|
||||
| 系统恢复 | ✅ 完成 | 支持完整恢复和恢复前备份 |
|
||||
| 健康检查 API | ✅ 完成 | 检测数据库、MinIO、资源使用 |
|
||||
| 健康检查页面 | ✅ 完成 | 实时监控系统状态 |
|
||||
| 配置验证 | ✅ 完成 | 验证环境变量和依赖服务 |
|
||||
| 生产环境配置 | ✅ 完成 | docker-compose.prod.yml |
|
||||
| 部署文档 | ✅ 完成 | 完整的部署和运维指南 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 备份恢复系统
|
||||
|
||||
### 备份脚本
|
||||
|
||||
1. **数据库备份** (`scripts/backup-db.ps1`)
|
||||
- 自动检测 Docker 环境
|
||||
- 支持 PostgreSQL 容器备份
|
||||
- 生成压缩备份文件 (.sql.gz)
|
||||
- SHA256 校验和验证
|
||||
- 保留最近 5 份备份
|
||||
|
||||
2. **文件存储备份** (`scripts/backup-files.ps1`)
|
||||
- 支持 MinIO Docker 容器
|
||||
- 使用 docker cp 复制数据
|
||||
- 本地压缩为 tar.gz
|
||||
- 保留最近 5 份备份
|
||||
|
||||
3. **完整备份** (`scripts/backup-all.ps1`)
|
||||
- 一键备份数据库和文件
|
||||
- 备份配置文件 (.env, docker-compose.yml)
|
||||
- 生成备份清单 (manifest.json)
|
||||
- 自动清理过期备份(默认 5 天)
|
||||
|
||||
### 恢复脚本
|
||||
|
||||
- **系统恢复** (`scripts/restore.ps1`)
|
||||
- 恢复前自动备份当前数据
|
||||
- 支持数据库恢复
|
||||
- 支持文件存储恢复
|
||||
- 交互式确认防止误操作
|
||||
|
||||
---
|
||||
|
||||
## 🏥 健康检查系统
|
||||
|
||||
### API 端点
|
||||
|
||||
- **GET /api/health** - 系统健康检查
|
||||
- 简单模式:返回 ok/error 状态
|
||||
- 详细模式:返回所有服务状态和资源使用
|
||||
|
||||
### 检查项目
|
||||
|
||||
- ✅ 数据库连接状态
|
||||
- ✅ API 服务状态
|
||||
- ✅ CPU 使用率
|
||||
- ✅ 内存使用率
|
||||
- ✅ 磁盘使用率(可选)
|
||||
|
||||
### 健康检查页面
|
||||
|
||||
- 路径:`/system-health`
|
||||
- 实时显示系统状态
|
||||
- 支持手动/自动刷新
|
||||
- 可配置刷新间隔
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker 配置
|
||||
|
||||
### 生产环境配置 (`docker-compose.prod.yml`)
|
||||
|
||||
- 资源限制(CPU、内存)
|
||||
- 健康检查配置
|
||||
- 自动重启策略
|
||||
- 日志轮转配置
|
||||
- 安全配置
|
||||
|
||||
### 配置验证 (`scripts/validate-config.ps1`)
|
||||
|
||||
- 验证环境变量
|
||||
- 检查端口可用性
|
||||
- 验证依赖服务
|
||||
- 检查开发环境
|
||||
- 检查 Docker 状态
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档
|
||||
|
||||
### 已创建文档
|
||||
|
||||
1. **部署指南** (`docs/部署指南.md`)
|
||||
- 环境要求
|
||||
- 详细部署步骤
|
||||
- 配置参数说明
|
||||
- 部署检查清单
|
||||
|
||||
2. **运维指南** (`docs/运维指南.md`)
|
||||
- 备份恢复操作
|
||||
- 监控指南
|
||||
- 故障排查
|
||||
- 日常维护
|
||||
|
||||
3. **README.md** - 更新项目状态和快速开始指南
|
||||
|
||||
---
|
||||
|
||||
## ✅ 验收测试结果
|
||||
|
||||
### 功能验收
|
||||
|
||||
| 测试项 | 结果 | 备注 |
|
||||
|-------|------|------|
|
||||
| 数据库备份 | ✅ 通过 | Docker 环境正常 |
|
||||
| 文件存储备份 | ✅ 通过 | MinIO 容器正常 |
|
||||
| 完整备份 | ✅ 通过 | 所有组件正常 |
|
||||
| 数据库恢复 | ✅ 通过 | 数据完整恢复 |
|
||||
| 健康检查 API | ✅ 通过 | 响应时间 < 1s |
|
||||
| 配置验证 | ✅ 通过 | 所有检查通过 |
|
||||
|
||||
### 性能验收
|
||||
|
||||
| 指标 | 目标 | 实际 | 状态 |
|
||||
|-----|------|------|------|
|
||||
| 数据库备份时间 | < 5分钟 | ~1秒 | ✅ |
|
||||
| 文件备份时间 | < 10分钟 | ~1秒 | ✅ |
|
||||
| 健康检查响应 | < 1秒 | ~200ms | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🔮 后续建议
|
||||
|
||||
1. **定时备份**:配置 cron/任务计划程序定时执行备份
|
||||
2. **远程备份**:将备份文件同步到远程存储
|
||||
3. **监控告警**:集成监控系统,异常时发送告警
|
||||
4. **负载测试**:进行生产环境负载测试
|
||||
|
||||
---
|
||||
|
||||
## 📁 相关文件
|
||||
|
||||
```
|
||||
furniture-3d-system/
|
||||
├── scripts/
|
||||
│ ├── backup-db.ps1 # 数据库备份
|
||||
│ ├── backup-files.ps1 # 文件存储备份
|
||||
│ ├── backup-all.ps1 # 完整备份
|
||||
│ ├── restore.ps1 # 系统恢复
|
||||
│ └── validate-config.ps1 # 配置验证
|
||||
├── docker-compose.prod.yml # 生产环境配置
|
||||
├── docs/
|
||||
│ ├── 部署指南.md
|
||||
│ ├── 运维指南.md
|
||||
│ └── Phase10完成报告.md
|
||||
└── src/app/
|
||||
├── api/health/route.ts # 健康检查 API
|
||||
└── system-health/ # 健康检查页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**报告生成时间**:2025-12-22 18:17
|
||||
**项目状态**:生产就绪 ✅
|
||||
@ -1,280 +0,0 @@
|
||||
# Precision Atelier 设计系统改造记录
|
||||
|
||||
## 改动日期
|
||||
- 2026-04-02:视觉层改造(配色、字体、组件样式)
|
||||
- 2026-04-03:全局布局改造(AppShell 三栏布局 + 去重)
|
||||
|
||||
## 背景
|
||||
基于 `方案设定/stitch/` 中的 17 份前端设计稿(solidjoin_3d_1~15 + solidjoin_precision + solidjoin_3d_inventory_2),将前端视觉风格从 "Apple HIG" 切换为设计稿指定的 "Precision Atelier" 风格。
|
||||
|
||||
## 核心变更
|
||||
|
||||
### 1. 配色体系(colors.ts)
|
||||
|
||||
| 维度 | 旧值 | 新值 | 用途 |
|
||||
|------|------|------|------|
|
||||
| 品牌色 | `#008D74`(Teal) | `#546067`(Graphite) | 导航高亮、标题、CTA |
|
||||
| 次要色 | `#47C4AF` | `#4b626e`(Fog Blue) | 链接、辅助操作 |
|
||||
| 成功色 | `#34C759` | `#286c34`(Sage Green) | 成功状态、验证 |
|
||||
| 错误色 | `#FF3B30` | `#9f403d` | 错误提示 |
|
||||
| 主背景 | `#FFFFFF` | `#f9f9fb`(surface) | 页面底色 |
|
||||
| 侧边栏 | `#F5F5F7` | `#f2f4f6`(surface-container-low) | 侧边栏 |
|
||||
| 卡片/浮动面板 | `#FFFFFF` | `#ffffff`(surface-container-lowest) | 卡片 |
|
||||
| 二级工作区 | — | `#ebeef2`(surface-container) | 数据区域 |
|
||||
| 深色背景 | `#000000` / `#1C1C1E` | `#0c0e10` / `#1a1c1e` | 深色主题 |
|
||||
|
||||
新增令牌:
|
||||
- `glassmorphismTokens` — 玻璃面板颜色(70% 白 + blur 20px)
|
||||
- `ambientShadowTokens` — 环境阴影(带 on-surface 色调 `rgba(45, 51, 56, 0.06)`)
|
||||
- `toastColors` — Toast 专用颜色
|
||||
|
||||
### 2. 字体系统(typography.ts)
|
||||
|
||||
双字体策略:
|
||||
- **展示字体**:Manrope — 用于品牌标题、大标题
|
||||
- **功能字体**:Inter — 用于数据、标签、表单、技术规格
|
||||
|
||||
命名体系对照:
|
||||
| 旧名 | 新名 | 用途 |
|
||||
|------|------|------|
|
||||
| largeTitle | displayLg (3.5rem) | 品牌大标题 |
|
||||
| title1 | displayMd (2.5rem) | 展示标题 |
|
||||
| title2 | headlineLg (1.75rem) | 页面标题 |
|
||||
| title3 | headlineMd (1.5rem) | 区块标题 |
|
||||
| headline | headlineSm (1.25rem) | 小标题 |
|
||||
| body | bodyMd (0.875rem) | 正文 |
|
||||
| callout | bodyLg (1rem) | 大正文 |
|
||||
| subhead | bodySm (0.75rem) | 小正文 |
|
||||
| footnote | bodySm (0.75rem) | 注释 |
|
||||
| caption1 | bodySm (0.75rem) | 小标签 |
|
||||
| caption2 | labelSm (0.6875rem) | 微标签 |
|
||||
|
||||
兼容处理:`legacyTextStyleMap` 映射旧名到新名,`CompatibleTextStyle` 类型同时接受新旧名。
|
||||
|
||||
### 3. 全局样式(globals.css)
|
||||
|
||||
- **@theme 块**:颜色变量全部替换为 Precision Atelier 配色
|
||||
- **字间距**:展示标题统一 `letter-spacing: -0.02em`,技术标签 `0.05em`
|
||||
- **圆角**:改为紧凑风格(0.375rem ~ 1rem)
|
||||
- **过渡**:200ms ease-out("Snappy but Silky")
|
||||
- **阴影**:环境阴影 `0px 4px 32px rgba(45, 51, 56, 0.06)`
|
||||
- **Ghost Border**:`rgba(172, 179, 184, 0.3)` 用于数据网格分隔
|
||||
- **深色主题**:完整的深色变量覆盖
|
||||
- **Glassmorphism**:70% 白/深 + 20px blur
|
||||
- **滑块**:品牌色从 `#008D74` 改为 `#546067`,深色模式对应
|
||||
|
||||
### 4. UI 组件改动
|
||||
|
||||
**Button.tsx**
|
||||
- filled 变体:纯色背景改为 145° 渐变 `linear-gradient(145deg, var(--color-blue), var(--color-teal))`
|
||||
|
||||
**Typography.tsx**
|
||||
- `variant` prop 类型改为 `CompatibleTextStyle`,兼容旧名(caption1/body/headline 等)
|
||||
- `fontType` 新增 `display` 选项,支持 Manrope 展示字体
|
||||
|
||||
### 5. 硬编码颜色替换
|
||||
|
||||
~20 个业务文件中的 82 处 `#008D74` 硬编码替换为 CSS 变量引用。
|
||||
|
||||
### 6. 兼容性处理
|
||||
|
||||
- `appleColorTokens`、`appleSemanticColors`、`appleToastColors`:旧名兼容导出
|
||||
- `appleTypographyTokens`:旧名兼容导出
|
||||
- `appleAnimationTokens`:保持原名不变
|
||||
- `precisionAtelierSpacingTokens`:新名,旧函数签名不变
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **No-Line Rule**:不用 1px 边框,通过 surface 层级的 hex 差异产生视觉分离
|
||||
2. **Glassmorphism**:浮动面板用 70% 白 + 20px blur
|
||||
3. **环境阴影**:仅用于浮动元素,颜色带 on-surface 色调
|
||||
4. **Snappy but Silky**:200ms ease-out 过渡
|
||||
5. **Ghost Border**:数据网格用 0.5px `outline-variant` @ 30% 透明度
|
||||
6. **CTA 渐变**:primary → primary-dim,145° 角,"machined metal" 质感
|
||||
|
||||
## 改动文件清单
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|----------|
|
||||
| `src/lib/design-tokens/colors.ts` | 全部重写 |
|
||||
| `src/lib/design-tokens/typography.ts` | 全部重写 |
|
||||
| `src/lib/design-tokens/spacing.ts` | 重命名+圆角调整 |
|
||||
| `src/lib/design-tokens/index.ts` | 注释更新 |
|
||||
| `src/app/globals.css` | 颜色/字体/阴影全部替换 |
|
||||
| `src/components/ui/Button.tsx` | CTA 渐变效果 |
|
||||
| `src/components/ui/Typography.tsx` | 兼容旧名 variant |
|
||||
| ~20 个业务文件 | 硬编码颜色替换 |
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:全局布局改造(2026-04-03)
|
||||
|
||||
### 7. AppShell 三栏布局
|
||||
|
||||
新增全局布局框架,所有常规页面统一使用三栏结构:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ AppTopNav(h-14,Glassmorphism 毛玻璃顶栏) │
|
||||
│ Logo + 搜索 + 通知 + 主题切换 + 用户菜单 │
|
||||
├──────────┬──────────────────────────────────────┤
|
||||
│ AppSideNav│ 主内容区(ml-56 mt-14) │
|
||||
│ w-56 │ │
|
||||
│ │ PlatformSplitLayout 等页面组件 │
|
||||
│ 导航链接 │ │
|
||||
│ 新建配置 │ │
|
||||
│ 技术支持 │ │
|
||||
│ 归档 │ │
|
||||
└──────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**新增文件:**
|
||||
- `src/components/layout/AppTopNav.tsx` — 固定顶栏(Logo + 搜索 + 工具按钮)
|
||||
- `src/components/layout/AppSideNav.tsx` — 固定左侧导航(5 个导航项 + 底部操作区)
|
||||
- `src/components/layout/AppShell.tsx` — 全局布局容器,组合 TopNav + SideNav + main
|
||||
|
||||
**排除页面:** `/login`、`/first-login`、`/debug-theme`、`/ui-preview` 不使用 AppShell。
|
||||
|
||||
**AppSideNav 导航项:**
|
||||
| 路由 | 标签 | 图标 |
|
||||
|------|------|------|
|
||||
| `/` | 首页概览 | Home |
|
||||
| `/asset-center` | 资产中心 | Boxes |
|
||||
| `/assisted-assembly` | 辅助装配 | Layers |
|
||||
| `/draft-groups` | 草稿组 | PackageOpen |
|
||||
| `/warehouse` | 仓库管理 | Warehouse |
|
||||
|
||||
### 8. 功能去重
|
||||
|
||||
引入 AppShell 后,各页面中与全局导航重复的元素已清理:
|
||||
|
||||
**去重 1:顶栏导航 tab 移除**
|
||||
- AppTopNav 原本有 5 个导航 tab(首页/资产中心/辅助装配/草稿组/仓库),与 AppSideNav 完全重复
|
||||
- 已移除顶栏中的导航 tab,顶栏只保留:Logo、搜索框、通知铃铛、主题切换、用户菜单
|
||||
- 导航职责统一由左侧边栏承担
|
||||
|
||||
**去重 2:页面内 headerTools(ThemeToggle + UserMenu)移除**
|
||||
以下 7 个文件移除了 `headerTools={<PlatformHeaderTools user={user} />}` 传参:
|
||||
- `src/app/asset-center/AssetCenterClient.tsx`
|
||||
- `src/app/assisted-assembly/page.tsx`(服务端)+ `AssistedAssemblyClient.tsx`(客户端)
|
||||
- `src/app/draft-groups/DraftGroupsClient.tsx`
|
||||
- `src/app/draft-groups/[id]/DraftGroupDetailClient.tsx`
|
||||
- `src/app/intake/IntakeClient.tsx`
|
||||
- `src/app/warehouse/WarehouseClient.tsx`
|
||||
- `src/app/HomeClient.workspace.tsx`
|
||||
|
||||
**去重 3:eyebrow 副标题移除**
|
||||
各页面中 `eyebrow` prop(如"主链起点"、"系列模板驱动"、"中台承接页"等)已全部移除。
|
||||
侧边栏已有清晰的页面标识,无需重复。
|
||||
|
||||
### 9. Platform 组件适配
|
||||
|
||||
**PlatformPageFrame.tsx**
|
||||
- 移除:radial-gradient 背景、rounded-[28px] 卡片包装、liquid-glass-enhanced 头部
|
||||
- 现在:简洁的内联布局,Manrope 标题 + 内联 eyebrow badge + PlatformFlowNav
|
||||
|
||||
**PlatformSplitLayout.tsx**
|
||||
- 移除:独立的 sticky 头部(含 Card、返回按钮、eyebrow)
|
||||
- 适配:`h-[calc(100vh-56px)]` 填满 AppShell 主内容区
|
||||
- 右侧面板:w-80(320px),surface-container-lowest 背景
|
||||
- `eyebrow` 和 `headerTools` 保留为 `@deprecated` props(解构后静默忽略),确保旧代码不报错
|
||||
|
||||
## 最终布局职责划分
|
||||
|
||||
| 区域 | 组件 | 职责 |
|
||||
|------|------|------|
|
||||
| 顶栏 | AppTopNav | Logo、搜索、通知、主题切换、用户菜单 |
|
||||
| 侧边栏 | AppSideNav | 5 个导航链接、新建配置按钮、技术支持、归档 |
|
||||
| 主内容区 | 各页面组件 | 只负责业务内容,不再自带导航/头部工具 |
|
||||
|
||||
## 改动文件清单(完整)
|
||||
|
||||
| 文件 | 改动类型 |
|
||||
|------|----------|
|
||||
| `src/lib/design-tokens/colors.ts` | 全部重写 |
|
||||
| `src/lib/design-tokens/typography.ts` | 全部重写 |
|
||||
| `src/lib/design-tokens/spacing.ts` | 重命名+圆角调整 |
|
||||
| `src/app/globals.css` | 颜色/字体/阴影全部替换 |
|
||||
| `src/components/ui/Button.tsx` | CTA 渐变效果 |
|
||||
| `src/components/ui/Typography.tsx` | 兼容旧名 variant |
|
||||
| `src/components/layout/AppTopNav.tsx` | **新增**:全局顶栏 |
|
||||
| `src/components/layout/AppSideNav.tsx` | **新增**:全局侧边导航 |
|
||||
| `src/components/layout/AppShell.tsx` | **新增**:全局布局容器 |
|
||||
| `src/app/layout.tsx` | 引入 AppShell 包裹 |
|
||||
| `src/components/platform/PlatformPageFrame.tsx` | 简化为内联布局 |
|
||||
| `src/components/platform/PlatformSplitLayout.tsx` | 适配 AppShell + deprecated props |
|
||||
| `src/app/asset-center/AssetCenterClient.tsx` | 移除 headerTools/eyebrow |
|
||||
| `src/app/assisted-assembly/page.tsx` | 移除 headerTools 传参 |
|
||||
| `src/app/assisted-assembly/AssistedAssemblyClient.tsx` | headerTools 改为可选 |
|
||||
| `src/app/draft-groups/DraftGroupsClient.tsx` | 移除 headerTools/eyebrow |
|
||||
| `src/app/draft-groups/[id]/DraftGroupDetailClient.tsx` | 移除 headerTools/eyebrow(2 处) |
|
||||
| `src/app/intake/IntakeClient.tsx` | 移除 headerTools/eyebrow(2 处) |
|
||||
| `src/app/warehouse/WarehouseClient.tsx` | 移除 headerTools/eyebrow |
|
||||
| `src/app/HomeClient.workspace.tsx` | 移除 topTools 传参 |
|
||||
| ~20 个业务文件 | 硬编码颜色替换 |
|
||||
|
||||
---
|
||||
|
||||
## 第三阶段:管理基础设施视觉升级 (2026-04-16)
|
||||
|
||||
### 10. 管理端专属视觉模式 (Management UI Patterns)
|
||||
|
||||
在重构 `/admin` 和 `/users` 页面时,引入了针对“高密度控制类页面”的增强视觉模式,以平衡“工业精密感”与“视觉通透度”。
|
||||
|
||||
**1. 沉浸式页头 (Immersive Page Header)**
|
||||
- **视觉特征**:
|
||||
- 采用 `bg-white/60` + `backdrop-blur-2xl`。
|
||||
- 强化左侧 Icon 视觉,使用 `rounded-[2rem] bg-black` 黑色底座,并叠加状态徽章(如 `ShieldCheck`)。
|
||||
- **排版建议**:Manrope 3XL 标题 + 紧跟其后的 `text-apple-blue` 高亮关键词。
|
||||
- **副标题**:使用 `text-[10px] tracking-[0.4em] uppercase` 的精密说明文字。
|
||||
|
||||
**2. 精密控制条 (Precision Control Bar)**
|
||||
- **定义**:将原本分散的“搜索框”与“筛选器”整合进一个逻辑整体。
|
||||
- **视觉实现**:
|
||||
- 容器:`rounded-[2rem]` 极圆圆角,`border-white/80` 微光边框,`bg-white/40` 背景。
|
||||
- 搜索框:沉浸式设计,背景为 `bg-white/80`,聚焦时通过 `ring-apple-blue/5` 产生柔和晕染。
|
||||
- 筛选器:图标 (Lucide Icon) + Select 组件的紧凑组合,图标作为装饰前缀。
|
||||
|
||||
**3. 玻璃态卡片增强 (Enhanced Glass Cards)**
|
||||
- **改动**:在 `rounded-[2rem]` 的基础上,引入了“装饰线”动效。
|
||||
- **动效逻辑**:`group-hover:w-full` 的底部 1.5px 强调线,颜色与模块 Icon 色调一致。
|
||||
- **背景文字装饰**:绝对定位的 `text-[80px]` 背景大字(如模块 path),设置 `opacity-[0.03]` 极低透明度,增加“空间感”。
|
||||
|
||||
**4. 状态指标看板 (System Health Dashboard)**
|
||||
- 应用场景:`/admin` 首页。
|
||||
- 组成:Icon 容器 + 上下两行文本(上行为微缩标签,下行为状态值)。
|
||||
- 配色:动态使用 `emerald-500` (正常), `blue-500` (活动), `amber-500` (注意)。
|
||||
|
||||
**5. 三栏工作区 (Three-Column Workspace)**
|
||||
- 应用场景:`/asset-center/material-library`。
|
||||
- 定义:左侧极简分类 (Navigation) + 中间响应式网格 (Grid) + 右侧实时定义面板 (Inspector)。
|
||||
- 优势:极大降低页面跳转成本,实现“即时反馈”。
|
||||
|
||||
**6. 精密滑块交互 (Precision Sliders)**
|
||||
- 视觉表现:细条轨道 + 圆形 Thumb + 右侧数值百分比徽章。
|
||||
- 应用场景:物理参数调节 (Metallic/Roughness/Opacity)。
|
||||
- 规范:取代原有的输入框,滑动步长固定为 0.01 (1%)。
|
||||
|
||||
**7. 悬浮坞系统 (Floating Dock System)**
|
||||
- 应用场景:`/assisted-assembly`。
|
||||
- 定义:左侧布局的交互式入口,采用 `bg-black/60` + `backdrop-blur-3xl`。
|
||||
- 动效:点击后向右水平展开列表。
|
||||
- 逻辑:通过视觉发光 (Glow) 和计数徽标反馈当前分类的选择状态。
|
||||
|
||||
**8. 环境 HUD (Ambient HUD)**
|
||||
- 定义:底部的非侵入式信息条。
|
||||
- 样式:极圆胶囊型,`backdrop-blur-xl`。
|
||||
- 作用:提供当前操作环境的实时状态(如“捕捉已就绪”)。
|
||||
|
||||
### 11. 改动文件清单
|
||||
|
||||
| 文件 | 改动类型 | 备注 |
|
||||
|------|----------|------|
|
||||
| `src/app/admin/page.tsx` | 重构 | 系统总控制台首页视觉重载 |
|
||||
| `src/app/users/page.tsx` | 重构 | 身份控制中心 (用户管理) 三栏重写 |
|
||||
| `src/app/asset-center/material-library/MaterialLibraryClient.tsx` | 重构 | 材质库首页视觉重写 (草稿图 12.png) |
|
||||
| `src/app/assisted-assembly/AssistedAssemblyClient.tsx` | 重构 | 拼装工作台沉浸式重写 & 引入 Floating Dock |
|
||||
| `src/components/platform/PlatformSplitLayout.tsx` | 基础重构 | 统一加装 backHref 导航标准 |
|
||||
|
||||
|
||||
|
||||
183
docs/README.md
Normal file
183
docs/README.md
Normal file
@ -0,0 +1,183 @@
|
||||
# 家具开品平台 - 文档导航
|
||||
|
||||
> **最后更新**:2026-03-31
|
||||
> **项目状态**:新平台一期骨架已落地,旧 `CAD -> 装配步骤` 链继续作为兼容链保留
|
||||
|
||||
---
|
||||
|
||||
## 📖 快速导航
|
||||
|
||||
### 🎯 了解项目进度
|
||||
- **[项目总览](./项目总览.md)** - 查看整体进度、功能完成度、下一步计划
|
||||
- **[长期决策日志](./agent-memory/decision-log.md)** - 查看最近做过的重要技术与流程决策
|
||||
- **[长期待办与阻塞](./agent-memory/open-issues.md)** - 查看当前仍在收口的问题
|
||||
|
||||
### 🏗️ 架构设计
|
||||
- **[认证系统设计](./架构设计/auth-system-design.md)** - 用户认证和首次登录流程
|
||||
- **[权限系统设计](./架构设计/permission-design-v2.md)** - 6 种角色的权限体系
|
||||
|
||||
### 📚 开发指南
|
||||
- **[设计系统](./开发指南/设计指南.md)** - Apple 风格 UI 组件使用
|
||||
- **[组件使用指南](./开发指南/组件使用指南.md)** - 常用组件的使用方法
|
||||
- **[上传 API 指南](./开发指南/上传API指南.md)** - 文件上传功能使用
|
||||
- **[性能优化指南](./开发指南/性能优化指南.md)** - 性能优化最佳实践
|
||||
- **[数据库优化](./开发指南/数据库优化.md)** - 数据库查询优化
|
||||
- **[快速测试指南](./开发指南/快速测试指南.md)** - 测试相关指南
|
||||
- **[项目上下文](./agent-memory/project-context.md)** - 当前高频回归点、默认工作流、项目目标
|
||||
|
||||
### 🤝 团队协作
|
||||
- **[团队协作模式](./workflow/团队协作模式.md)** - 当前推荐分工、分支规则、提交流程
|
||||
- **[高频协作五条铁律](./workflow/高频协作五条铁律.md)** - 双 Codex 高频协作时的 5 条固定规则
|
||||
- **[主线同步操作手册](./workflow/主线同步操作手册.md)** - 主线持续更新时,同事如何安全同步自己的分支
|
||||
- **[同事本地 AGENTS 使用说明](./workflow/同事本地AGENTS使用说明.md)** - 如何给同事单独加 AI 约束,而不污染仓库共享规则
|
||||
- **[同事本地 AGENTS 模板](./workflow/同事本地AGENTS模板.md)** - 可直接复制到同事电脑外层工作区的 `AGENTS.md` 模板
|
||||
- **[模块边界说明](./workflow/模块边界说明.md)** - 新平台主链、包装协作、旧 CAD 兼容链的边界
|
||||
- **[任务单模板](./workflow/任务单模板.md)** - 给人和 Codex 共用的任务模板
|
||||
- **[提测与验收清单](./workflow/提测与验收清单.md)** - 提交前必须过的最小检查
|
||||
- **[同事交接模板](./workflow/同事交接模板.md)** - 给非程序员同事和 Codex 的交接模板
|
||||
|
||||
### 🧠 长期记忆与问题收口
|
||||
- **[长期记忆导航](./agent-memory/README.md)** - 当前长期记录入口
|
||||
- **[长期决策日志](./agent-memory/decision-log.md)** - 近期关键决策
|
||||
- **[长期待办与阻塞](./agent-memory/open-issues.md)** - 当前问题收口列表
|
||||
|
||||
### 📊 阶段完成报告(历史阶段)
|
||||
- **[Phase 1: 数据库模型重构](./阶段报告/PHASE1_COMPLETE.md)**
|
||||
- **[Phase 2: 认证系统](./阶段报告/PHASE2_COMPLETE.md)**
|
||||
- **[Phase 3: 权限系统](./阶段报告/PHASE3_COMPLETE.md)**
|
||||
- **[Phase 4: 组织架构](./阶段报告/PHASE4_COMPLETE.md)**
|
||||
- **[Phase 5: 产品归属](./阶段报告/PHASE5_COMPLETE.md)**
|
||||
- **[Phase 6: 离职交接](./阶段报告/PHASE6_COMPLETE.md)**
|
||||
- **[Phase 7: 问题反馈](./阶段报告/PHASE7_COMPLETE.md)**
|
||||
- **[Phase 8: 数据安全与日志](./阶段报告/PHASE8_COMPLETE.md)**
|
||||
- **[Phase 9: 测试与优化](./阶段报告/Phase9总结.md)**
|
||||
- **[Phase 10 完成报告](./Phase10完成报告.md)**
|
||||
|
||||
### 🧪 测试报告
|
||||
- **[安全测试报告](./测试报告/安全测试报告.md)** - SQL 注入、XSS、权限测试
|
||||
- **[性能测试报告](./测试报告/性能测试报告.md)** - 前端性能优化结果
|
||||
- **[登录测试报告](./测试报告/登录测试报告.md)** - 登录流程测试
|
||||
- **[主页测试报告](./测试报告/主页测试报告.md)** - 主页功能测试
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 查看项目进度
|
||||
```bash
|
||||
# 查看项目总览
|
||||
cat docs/项目总览.md
|
||||
```
|
||||
|
||||
### 2. 了解架构设计
|
||||
```bash
|
||||
# 查看权限系统设计
|
||||
cat docs/架构设计/permission-design-v2.md
|
||||
```
|
||||
|
||||
### 3. 开始开发
|
||||
```bash
|
||||
# 查看快速开始指南
|
||||
cat ../README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📂 目录结构
|
||||
|
||||
```
|
||||
docs/
|
||||
├── README.md # 📖 本文档(导航入口)
|
||||
├── 项目总览.md # 🎯 项目整体情况
|
||||
├── PHASES_8_9_SUMMARY.md # 📊 最新阶段总结
|
||||
├── workflow/ # 🤝 团队协作规则
|
||||
│ ├── 团队协作模式.md
|
||||
│ ├── 高频协作五条铁律.md
|
||||
│ ├── 主线同步操作手册.md
|
||||
│ ├── 同事本地AGENTS使用说明.md
|
||||
│ ├── 同事本地AGENTS模板.md
|
||||
│ ├── 模块边界说明.md
|
||||
│ ├── 任务单模板.md
|
||||
│ ├── 提测与验收清单.md
|
||||
│ ├── 同事交接模板.md
|
||||
│ └── 规范审查清单.md
|
||||
│
|
||||
├── 架构设计/ # 🏗️ 架构相关文档
|
||||
│ ├── auth-system-design.md # 认证系统设计
|
||||
│ └── permission-design-v2.md # 权限系统设计
|
||||
│
|
||||
├── 开发指南/ # 📚 开发相关指南
|
||||
│ ├── 设计指南.md
|
||||
│ ├── 组件使用指南.md
|
||||
│ ├── 上传API指南.md
|
||||
│ ├── 性能优化指南.md
|
||||
│ ├── 数据库优化.md
|
||||
│ └── 快速测试指南.md
|
||||
│
|
||||
├── 阶段报告/ # 📊 各阶段完成文档
|
||||
│ ├── PHASE1_COMPLETE.md
|
||||
│ ├── PHASE2_COMPLETE.md
|
||||
│ ├── ...
|
||||
│ ├── PHASE8_COMPLETE.md
|
||||
│ ├── Phase9总结.md # ⭐ 当前阶段
|
||||
│ ├── 第9阶段_第4天_上午完成.md
|
||||
│ ├── 第9阶段_第4天_下午完成.md
|
||||
│ ├── 第9阶段_第4天_总结.md
|
||||
│ └── 第9阶段_第5天_下午完成.md
|
||||
│
|
||||
├── 测试报告/ # 🧪 测试相关报告
|
||||
│ ├── 安全测试报告.md
|
||||
│ ├── 性能测试报告.md
|
||||
│ ├── 登录测试报告.md
|
||||
│ └── 主页测试报告.md
|
||||
│
|
||||
└── 历史归档/ # 📦 过时的文档归档
|
||||
├── database-redesign-product-sku.md
|
||||
├── LOGIN_FLOW_FIXED.md
|
||||
├── NEXT_STEPS.md
|
||||
├── PHASES_5_6_7_SUMMARY.md
|
||||
├── theme-adaptation-fix.md
|
||||
├── troubleshooting-theme.md
|
||||
├── 迁移指南.md
|
||||
├── 用户指南.md
|
||||
├── 主题测试指南.md
|
||||
└── 实施计划.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 当前状态
|
||||
|
||||
### 当前重点
|
||||
|
||||
- 新主线已切到:`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 正式入库 -> 轻仓库`
|
||||
- 旧 `CAD 导入 -> SKU 级结构数据 -> 装配编辑 -> 3D 回放` 继续保留为兼容链
|
||||
- 当前重点不是继续堆页面,而是先把资产精度、模块边界和团队协作规则立住
|
||||
|
||||
### 当前建议查看顺序
|
||||
|
||||
1. **[项目总览](./项目总览.md)**
|
||||
2. **[项目上下文](./agent-memory/project-context.md)**
|
||||
3. **[长期待办与阻塞](./agent-memory/open-issues.md)**
|
||||
4. **[长期决策日志](./agent-memory/decision-log.md)**
|
||||
|
||||
---
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
### 常见问题
|
||||
1. **如何查看项目进度?** → 查看 [项目总览](./项目总览.md)
|
||||
2. **如何了解权限系统?** → 查看 [权限系统设计](./架构设计/permission-design-v2.md)
|
||||
3. **如何了解认证系统?** → 查看 [认证系统设计](./架构设计/auth-system-design.md)
|
||||
4. **如何优化性能?** → 查看 [性能优化指南](./开发指南/性能优化指南.md)
|
||||
|
||||
### 联系方式
|
||||
- 查看代码注释
|
||||
- 查看相关文档
|
||||
- 参考已完成的代码示例
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v2.2
|
||||
**最后更新**:2026-03-31
|
||||
**维护者**:开发团队
|
||||
44
docs/agent-memory/README.md
Normal file
44
docs/agent-memory/README.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Agent 长期记忆目录
|
||||
|
||||
> 目的:避免“换 Agent / 重启会话后失忆”,把关键事实沉淀为仓库文档。
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `project-context.md`:项目现状、硬约束、当前优先级。
|
||||
- `decision-log.md`:关键技术决策与原因(按日期追加)。
|
||||
- `open-issues.md`:未完成项、阻塞项、下一步动作。
|
||||
- `regression-checklist.md`:高风险功能回归清单(通过/失败状态)。
|
||||
|
||||
## 使用规则(强制)
|
||||
|
||||
1. 每轮任务开始前:先读上述 4 个文件。
|
||||
2. 每轮任务结束后:至少更新 `decision-log.md` 与 `open-issues.md`。
|
||||
3. 声称“已修复/已完成”前:更新 `regression-checklist.md` 的验证结果。
|
||||
4. 长期事实只认本目录,不依赖临时 Agent 会话记忆。
|
||||
|
||||
## 容量上限(强制)
|
||||
|
||||
- `project-context.md`:不超过 220 行。
|
||||
- `decision-log.md`:不超过 150 条决策或 24KB。
|
||||
- `open-issues.md`:不超过 120 行。
|
||||
- `regression-checklist.md`:不超过 220 行。
|
||||
- 本目录总大小建议不超过 180KB(不含 `archive/`)。
|
||||
|
||||
超过上限时,先执行“压缩+归档”再继续新任务。
|
||||
|
||||
## 归档规则
|
||||
|
||||
- 归档目录:`docs/agent-memory/archive/`
|
||||
- 归档命名:
|
||||
- `decision-log-YYYY-MM.md`
|
||||
- `open-issues-YYYY-MM.md`
|
||||
- `regression-checklist-YYYY-MM.md`
|
||||
- 当前文件顶部需保留:
|
||||
- 最新摘要(3~10 行)
|
||||
- 最近归档文件路径(便于追溯)
|
||||
|
||||
## 记录格式建议
|
||||
|
||||
- 日期:`YYYY-MM-DD`
|
||||
- 状态:`待处理 / 处理中 / 已完成 / 阻塞`
|
||||
- 每条记录尽量包含:问题、动作、证据(命令或文件路径)
|
||||
15
docs/agent-memory/decision-log.md
Normal file
15
docs/agent-memory/decision-log.md
Normal file
@ -0,0 +1,15 @@
|
||||
# 决策日志(长期)
|
||||
|
||||
| 日期 | 决策 | 原因 | 影响范围 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| 2026-03-13 | 协作流程收敛为“直接开发 + 规范审查闸门” | 避免运行时协作策略干扰主线开发,把硬规则稳定落到每次交付的固定步骤里 | 全项目默认协作方式、交付口径、审查流程 | 已生效 |
|
||||
| 2026-03-05 | SKU详情页/装配编辑器节点解析统一到“模型真实节点映射 + canonical 别名解析”,并将 3D 拾取容差改为“中心命中优先 + 最近命中优先” | 修复 SKU 详情与装配编辑器点选规则不一致、折角线锚点错位,以及点选漂移导致的误选/漏选 | `SKUDetailClient.workspace`、`install-order-node-resolution`、`model-viewer-pick-tolerance` | 已生效 |
|
||||
| 2026-02-27 | 装配编辑器本轮收口:修复可见乱码文案、步骤编辑器响应式布局、孔位高亮坐标解算改为“panel/modelRoot 双坐标根候选择优” | 解决页面可用性问题并降低孔位错绑/爆炸状态漂移 | `AssemblyEditorWorkspace`、`AssemblyStepEditor`、`HoleOverlay2D` | 已生效 |
|
||||
| 2026-02-26 | 安装顺序链路后端收口:在 Service 新增“保存顺序+生成步骤+返回最新步骤”编排方法 | 符合企业级分层,避免前端承载业务编排 | `assembly.service`、`generate-from-order route` | 已生效 |
|
||||
| 2026-02-26 | 生成失败后增加安装顺序补偿回滚,且 Route/Service 双层限制板件上限 300 | 降低部分成功状态与 DoS 风险 | `assembly.service`、`panel.repository`、`generate-from-order route` | 已生效 |
|
||||
| 2026-02-26 | 安装顺序“保存+生成”前端改为调用 `/api/products/[id]/assembly/generate-from-order` 单接口,并直接消费响应 `steps` 刷新步骤区 | 减少一次网络往返,避免额外 `GET /assembly` 带来的时序抖动 | `AssemblyEditorWorkspace` 安装顺序提交流程 | 已完成 |
|
||||
| 2026-02-26 | Agent 长期记忆从“会话上下文”切换为“仓库文档持久化” | 避免换会话或重启后丢失关键项目背景 | 全项目开发上下文与决策沉淀 | 已生效 |
|
||||
| 2026-02-26 | 开发阶段默认采用“批量闭环修复”,不再以最小改动为目标 | 当前处于快速开发与清债阶段 | 任务拆分、修复策略、审查节奏 | 已生效 |
|
||||
| 2026-02-26 | 安装顺序编辑器移除 `key` 强制重挂载,改为内部状态同步 | 降低重挂载性能损耗,保持数据同步 | `AssemblyEditorWorkspace`、`AssemblyStepEditor` | 已完成 |
|
||||
|
||||
> 追加规则:新决策按时间倒序追加到表格顶部。
|
||||
61
docs/agent-memory/open-issues.md
Normal file
61
docs/agent-memory/open-issues.md
Normal file
@ -0,0 +1,61 @@
|
||||
# 待办与阻塞(长期)
|
||||
|
||||
最后更新:2026-03-11
|
||||
|
||||
## P0(高优先级)
|
||||
|
||||
- 问题:SKU 详情页与 SKU 装配页“部分板材不可选/折角线贴合异常”已完成代码修复,但仍需基于真实模型做人工终验(自动化仅验证到开关/基础交互)。
|
||||
- 责任角色:QA 测试员 / 3D 工程师 / 前端工程师
|
||||
- 下一步:在 `3000` 端口对固定 SKU 执行全量点选回归(非抽样),逐板件验证:可选中、安装顺序可入列、折角线贴边。
|
||||
- 证据:`src/app/products/[id]/skus/[skuId]/SKUDetailClient.workspace.tsx`、`src/components/3d/model-viewer-pick-tolerance.ts`、`output/playwright/sku-regression-progress-2026-03-05T02-36-59-091Z/report.json`
|
||||
- 状态:处理中
|
||||
|
||||
- 问题:`HoleOverlay2D` 在 `requestAnimationFrame` 内仍有较重计算(每帧分组聚合与多次对象创建),孔位多时存在掉帧风险。
|
||||
- 责任角色:3D 工程师
|
||||
- 下一步:抽离“静态分组/标签聚合”到 `useMemo`,绘制环节仅做投影与绘制;减少帧内临时对象创建。
|
||||
- 证据:`src/components/3d/HoleOverlay2D.tsx`
|
||||
- 状态:待处理
|
||||
|
||||
## P1(中优先级)
|
||||
|
||||
- 问题:`generate-from-order` 仍缺“接口限频 + 幂等键”治理,网络抖动下存在重复生成与写放大风险。
|
||||
- 责任角色:后端工程师
|
||||
- 下一步:为该路由增加用户维度限频,并设计幂等键(同一次提交在短窗口只执行一次)。
|
||||
- 证据:`src/app/api/products/[id]/assembly/generate-from-order/route.ts`
|
||||
- 状态:待处理
|
||||
|
||||
- 问题:当前补偿回滚仅覆盖安装顺序,生成前创建版本的写放大仍需评估(失败时可能产生额外版本记录)。
|
||||
- 责任角色:后端工程师
|
||||
- 下一步:评估“版本创建时机后移”或“失败标记版本”策略,避免无效版本增长。
|
||||
- 证据:`src/server/services/assembly.service.ts`
|
||||
- 状态:待处理
|
||||
|
||||
- 问题:安装顺序单接口 `generate-from-order` 已接入,仍需联调确认异常场景与 `warnings` 展示策略。
|
||||
- 责任角色:前端工程师 / 后端工程师
|
||||
- 下一步:用真实产品执行“点选顺序 -> 保存并生成”,确认失败提示与步骤回填一致;评估是否补充 `warnings` UI。
|
||||
- 证据:`src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
|
||||
- 状态:待处理
|
||||
|
||||
- 问题:`AssemblyEditorWorkspace` 仍有历史视觉 token 不统一(如部分 `bg-white/text-white`)。
|
||||
- 责任角色:前端工程师
|
||||
- 下一步:按页面分区批量替换为语义 token,不改业务逻辑
|
||||
- 证据:`src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
|
||||
- 状态:待处理
|
||||
|
||||
- 问题:`AssemblyEditorWorkspace` 仍存在组件内直写 `fetch` 的历史实现,未完全收口到 hooks/数据层。
|
||||
- 责任角色:前端工程师
|
||||
- 下一步:按接口链路分批迁移到 `src/hooks/api`,先迁移装配页高频接口,保持行为不变。
|
||||
- 证据:`src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
|
||||
- 状态:待处理
|
||||
|
||||
## 阻塞项
|
||||
|
||||
> 暂无
|
||||
|
||||
## 记录模板
|
||||
|
||||
- 问题:
|
||||
- 责任角色:
|
||||
- 下一步:
|
||||
- 证据(文件/命令):
|
||||
- 状态:待处理 / 处理中 / 已完成 / 阻塞
|
||||
34
docs/agent-memory/project-context.md
Normal file
34
docs/agent-memory/project-context.md
Normal file
@ -0,0 +1,34 @@
|
||||
# 项目上下文(长期)
|
||||
|
||||
最后更新:2026-02-26
|
||||
|
||||
## 项目目标
|
||||
|
||||
- 核心:CAD 导入后,在装配编辑器中设置安装顺序并自动生成装配步骤。
|
||||
- 重点链路:`装配编辑器(3D) -> 安装顺序 -> 自动生成步骤 -> 回放验证`。
|
||||
|
||||
## 当前硬约束
|
||||
|
||||
- 后端必须遵循:`API Route -> Service -> Repository -> Database`。
|
||||
- 前端必须优先复用:`src/components/ui/` 元组件。
|
||||
- 开发阶段策略:批量闭环修复,不以“最小改动”为默认目标。
|
||||
- 交付前必须完成:规范审查;安全 / 性能 / QA 按任务风险追加。
|
||||
- 当前总规则入口:根目录 `AGENTS.md` 与 `docs/workflow/*.md`。
|
||||
|
||||
## 用户关注的高频回归点
|
||||
|
||||
- 孔位高亮位置与爆炸程度同步。
|
||||
- 折角线显示、文本与板材关联、边界不越出预览区。
|
||||
- 白膜模式灰度正确(不是改背景色)。
|
||||
- 板材可选中与高亮稳定。
|
||||
- 右键菜单层级、位置、可见区域裁剪、材质预览交互。
|
||||
- 爆炸程度滑块流畅性(无明显卡顿)。
|
||||
- 中文 UI 文案与样式规范一致。
|
||||
|
||||
## 当前默认工作流
|
||||
|
||||
- 默认由当前开发流程直接推进任务。
|
||||
- 开始前先明确:目标、验收标准、适用标准、根因判断。
|
||||
- 开发后先做最小必要验证,再进入规范审查阶段。
|
||||
- 规范审查固定检查:企业级架构标准、统一视觉标准、是否为根因修复。
|
||||
- 遇到安全、性能、关键用户链路时,再补安全 / 性能 / QA 审查。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user