Compare commits

...

No commits in common. "main" and "codex/open-platform-20260331" have entirely different histories.

5290 changed files with 70691 additions and 900588 deletions

View 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
View 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
View 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分钟 | 任何变更失效 |

View 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` |

View 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
View 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/ # 分享页面
```

View 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
View 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
View 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/ # 集成测试
```

View File

@ -75,6 +75,7 @@ dist
# Gatsby文件
.cache/
public
# Vuepress构建输出
.vuepress/dist
@ -224,4 +225,4 @@ PROJECT_SETUP_COMPLETE.md
产品安装展示系统-*.md
开发部署计划.md
项目*.md
架构*.md
架构*.md

View File

@ -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
View File

@ -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/

View File

@ -0,0 +1,21 @@
# 开发规范
## 🗣️ 交流语言
**重要**: 始终使用中文与用户交流
- 所有回复和解释用中文
- 错误信息和建议用中文
- 代码注释用中文
- 文档说明用中文
## 📋 项目信息
- **名称**: 3D家具安装展示系统
- **技术**: Next.js + Three.js + Apple UI
- **目标**: 现代化3D家具装配展示
## ⚡ 开发原则
- TypeScript 严格模式
- Apple HIG 2025 设计标准
- 性能优先 (React.memo + 懒加载)
- 代码整洁 (精简文档,避免冗余)
- 不重要的东西不要写文档
- 能在原有文档上更新就采用更新的方式

167
AGENTS.md
View File

@ -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 或同事交接时,写清:本次只改了什么、没改什么、验证跑了什么、风险是什么、下一步要看哪些文件。

View File

@ -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
- 中文沟通,面向非程序员,简短直白
- 优先复用现有实现,不顺手改无关问题
- 不以"最小改动"为默认目标,优先完成主需求闭环并收口同链路已发现问题

View File

@ -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
View File

@ -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
**维护者**:开发团队

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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);
}
}
}

View File

@ -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;
}
}

View File

@ -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),
},
};
}
}
}

View File

@ -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);
}
}
}

View File

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

View File

@ -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,
};
}
}
}

View File

@ -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]);
}
}
}

View File

@ -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 },
};
}
}
}

View File

@ -1 +0,0 @@
global using Xunit;

View File

@ -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 安装目录,例如:&#xA; set ACAD_PATH=D:\Program Files\Autodesk\AutoCAD 2024&#xA; dotnet build" />
<Message Importance="High" Text="使用 AutoCAD 路径: $(AcadPath)" />
</Target>
</Project>

View File

@ -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;
}
}
}

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -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]);
}
}

View File

@ -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();
}

View File

@ -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";
}
}

View File

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

View File

@ -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;
}
}

View File

@ -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; }
}
}

View File

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

View File

@ -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 ""

View File

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

View File

@ -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
}

View File

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

View File

@ -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 ""

View File

@ -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 ""

View File

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

View File

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

View File

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

View File

@ -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
View 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
View 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
**项目状态**:生产就绪 ✅

View File

@ -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 导航标准 |

175
docs/README.md Normal file
View File

@ -0,0 +1,175 @@
# 家具开品平台 - 文档导航
> **最后更新**: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)** - 新平台主链、包装协作、旧 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
│ ├── 提测与验收清单.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
**维护者**:开发团队

View 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`
- 状态:`待处理 / 处理中 / 已完成 / 阻塞`
- 每条记录尽量包含:问题、动作、证据(命令或文件路径)

View 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` | 已完成 |
> 追加规则:新决策按时间倒序追加到表格顶部。

View 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`
- 状态:待处理
## 阻塞项
> 暂无
## 记录模板
- 问题:
- 责任角色:
- 下一步:
- 证据(文件/命令):
- 状态:待处理 / 处理中 / 已完成 / 阻塞

View 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