diff --git a/EDGE_TREATMENT_CAPABILITY_REPORT.md b/EDGE_TREATMENT_CAPABILITY_REPORT.md new file mode 100644 index 0000000..8038dad --- /dev/null +++ b/EDGE_TREATMENT_CAPABILITY_REPORT.md @@ -0,0 +1,87 @@ +# M08/M09 F0:Manifold 内核能力探测报告 + +日期:2026-08-10 +范围:只读检查 `vendor/manifold/manifold.js`、`js/geometry.js` 与本地 Node/WASM 运行时;未修改生产源码,未启动浏览器、未下载依赖。 + +## 结论 + +**建议继续 F1(受限、纯网格的凸直棱倒角)进行原型验证;不建议直接进入 F2(实体圆角)的生产实现。** + +当前封装的 Manifold 能可靠执行网格构造、封闭性校验、布尔运算、切片和细分/平滑,但不提供可供 JavaScript 消费的 B-rep 拓扑面、拓扑棱边或原生 3D fillet/chamfer 算子。因此,真实倒角只能由 MiniSW 在 `MeshData` 层自行提取拓扑并重建网格;真实圆角还需要自行生成相切过渡面、处理边链与顶点角补片,风险显著更高。不得把 `smoothOut()` 当作实体圆角。 + +本地 vendor 文件不含可识别的 npm/package 版本元数据,因此本报告以实际导出和运行时行为为准,不猜测版本号。 + +## 基线与最小复现 + +- 执行:`node --loader ./tests/loader.mjs ./tests/test.mjs` +- 结果:64 通过、0 失败;几何内核初始化和既有实体布尔链路正常。 +- 运行时最小复现:`Manifold.cube([20,20,20], true)`,再调用 `getMesh()`、`decompose()`、`slice(0)`、`smoothOut()`、`refine(1)`,并在探测结束时手动释放全部创建的 WASM 对象。 +- 结果:立方体状态为 `NoError`,网格 8 顶点/12 三角形;`decompose()` 返回 1 个连通实体;`slice(0)` 返回 1 个二维多边形;`smoothOut()` 与 `refine(1)` 都返回 `NoError`。 + +## 已确认能力 + +| 能力 | 运行时接口/证据 | 对 M08/M09 的意义 | +|---|---|---| +| 网格读出 | `manifold.getMesh()` 返回 `vertProperties`、`triVerts`,并带可选 `faceID`、run/merge 数据 | 足以从 `MeshData` 合并共面三角形,自建拓扑面/边和稳定引用 | +| 网格构造与校验 | `new mod.Mesh({...})` + `new mod.Manifold(mesh)`;`status()` | 重建后的倒角/圆角网格可交由 Manifold 拒绝非流形结果 | +| 实体布尔 | `Manifold.union/difference/intersection`;现有 `geometry.js:booleanOp` 已统一封装 | 可用作工具体裁剪/并集的后备验证路线;不是原生边处理 | +| 组件分解 | `manifold.decompose()` | 仅按连通组件拆分,**不是**面/边分解,不能直接用于边选择 | +| 水平切片 | `manifold.slice(height)` 返回 `CrossSection`;`toPolygons()` 可读 | 可用于诊断或某些轴对齐特例;不能泛化为任意空间棱边圆角 | +| 细分/简化 | `refine`、`refineToLength`、`refineToTolerance`、`simplify` | 可改善离散网格质量,但不创建保持半径的圆角 | +| 平滑 | `smoothOut`、`smoothByNormals`、静态 `Manifold.smooth(mesh, sharpenedEdges)` | 是网格平滑/法线相关操作,非可控的几何圆角;不能用于 M09 验收 | +| 其他几何诊断 | `numVert/numEdge/numTri`、`volume`、`surfaceArea`、`genus`、`rayCast`、`boundingBox` | 可支持结果验证、性能统计及视口边拾取的射线辅助 | +| 二维偏移 | `CrossSection.offset(delta, joinType, ...)` | 只适用于 2D 截面;可服务某些专用工具体方案,不等于 3D 边偏移 | + +## 缺失能力与阻塞 + +1. **没有 B-rep 面/边枚举或邻接 API。** `numEdge()` 仅给数量;运行时没有 `faces()`、`edges()`、`faceID()` 等可遍历三维拓扑接口。`getMesh().faceID` 是可选来源/运行标识,不能作为完整稳定的几何边引用。 +2. **没有原生 3D chamfer/fillet/offset API。** 在 `manifold.js` 的导出和 Manifold 原型中未发现 `chamfer`、`fillet`、三维 `offset` 或曲面过渡算子。 +3. **`decompose()` 不是拓扑分面。** 它返回连通 Manifold 组件,无法定位“立方体的一条棱”。 +4. **`slice(height)` 仅限全局 Z 高度。** 它不能围绕任意选中边产生局部截面。 +5. **平滑不保特征语义。** `smoothOut()`/`smoothByNormals()` 无半径、目标边链、相切条件或顶点补片参数;用它冒充 R5 会改变不该处理的面且无法保证尺寸。 + +## 建议的 F1 可行路线:纯网格凸直棱倒角 + +前提:首版只接受封闭、方向一致、凸多面体上的直边;先做单条棱,再扩展相互不接触的多条平行棱。算法由 `js/features/edge-treatment.js` 独立实现,主代理随后负责注入 `geometry.js/doc.js/UI`。 + +1. 对 `MeshData` 以量化顶点坐标建立无向三角边表;合并共面的相邻三角形,得到逻辑平面与候选逻辑棱。 +2. 以相邻面法向、端点量化坐标、长度、二面角和凸性构造稳定边签名;重建时签名唯一匹配才解析,歧义或缺失必须明确报错。 +3. 对一条凸直棱,将其两侧面沿各自面内法向收缩,生成两条偏移边;以四边形(再三角化)替换原棱邻域,并处理端点局部拓扑。 +4. 严格检查倒角距离小于两侧可用面宽、未与其他待处理边冲突、输出无零面积三角形;构造后通过 `new mod.Manifold(new mod.Mesh(...)).status()` 做流形验收。 +5. 输出 `MeshData` 和边线段;WASM 对象只在主代理注入的上下文中创建和释放。 + +该路线可产生真实 STL 几何,但端点补片、多个相交选择边和凹边会迅速增加组合复杂度;首版不应承诺它们。 + +## F2 圆角评估 + +圆角在同一拓扑框架上还需:按半径生成圆弧截面、沿直棱扫出相切曲面、替换两侧面条带、在端点/边链转角处生成角补片,并保证共享顶点、面朝向和流形性。Manifold 可验证并布尔组合结果,却不替代这些构造步骤。 + +- **可验证的后备方案:** 仅对“贯穿整个实体、两端开放或端点可由明确平面封闭”的单条凸直棱,使用解析圆柱/四分之一圆柱工具体结合布尔差/并实现,并以截面、体积、流形状态验证。该方案仍需先可靠定位两相邻面与边局部坐标系。 +- **不可接受的方案:** 调用平滑 API、增加法线、仅在 Three.js 渲染高亮圆弧、或将草图圆角矩形改名。这些都不保证 STL 中存在真实 R 面。 +- **生产建议:** 在 F1 的拓扑提取、稳定引用、单边倒角、10 次重建稳定和独立网格流形测试完成前,暂停 F2。F2 首版范围应明确限制为立方体单条凸直棱 R 值,不承诺一般边链。 + +## 风险、复杂度与继续条件 + +| 项目 | 评估 | +|---|---| +| F1 单凸直棱倒角 | 中高复杂度;约 2–4 个实现/测试迭代,主要风险是端点补片和三角网格方向 | +| F1 多边选择 | 高复杂度;必须先检测相邻/相交选择边,否则偏移区域会重叠 | +| F2 单凸直棱圆角 | 高复杂度;需要圆柱曲面、两端补片、相切与半径上限校验 | +| F2 共线边链/一般边链 | 很高复杂度;需链识别、交界拼接和顶点角策略,当前不宜在同一轮承诺 | +| 性能 | 小型立方体可望低于 1 秒;必须设置三角数/分段数上限,避免无界细分 | + +继续 F1 的门槛:主代理认可“纯 MeshData 拓扑层”是 M08/M09 的正式基础,并允许先交付受限凸直棱功能。继续 F2 的门槛:F1 已通过闭合、体积、无退化三角形、稳定引用和 10 次重建测试;否则应以本报告记录技术阻塞,不做视觉伪实现。 + +## 对主代理的接入提示 + +F0 没有新增运行时代码或接口。若批准 F1,建议由子模块导出任务书中约定的 `extractTopology`、`makeStableEdgeRef`、`resolveEdgeRef`、`buildChamferMesh`;主代理再在 `geometry.js` 中以唯一已初始化的 `mod` 把 `MeshData` 转回 Manifold,并接入文档模型、拾取与序列化。 + +## 后续实施结果 + +本报告完成后,F1 的全部继续门槛已经通过,因此项目按报告建议继续了受限 F2: + +- M08 已实现凸多面体单条直棱的真实截切倒角、稳定引用和视口拾取。 +- M09 已实现凸多面体单条直棱的恒定半径多段圆柱过渡面;没有使用平滑 API。 +- 两者输出都使用共享索引的封闭 `MeshData`,并通过项目唯一 Manifold 内核的 `NoError`、正体积、无退化三角形和重复构建测试。 +- M08、M09 独立测试各 `5/5`,核心文档/AI/保存重建集成测试也已覆盖。 +- 安全范围仍严格限制为单条凸直棱;多边选择、边链、凹边和非凸实体按本报告风险结论明确拒绝。 diff --git a/PHASE1_M10_MANUAL_CHECKLIST.md b/PHASE1_M10_MANUAL_CHECKLIST.md new file mode 100644 index 0000000..833ab4f --- /dev/null +++ b/PHASE1_M10_MANUAL_CHECKLIST.md @@ -0,0 +1,77 @@ +# MiniSW 第一阶段 M10 手动验收清单 + +日期:2026-08-10 +状态:等待手动浏览器确认 +说明:M01~M09 的代码与自动化验收已完成;本文件只保留必须由人工观察的界面、交互和课程演练项目。全部勾选后,才在 `ROADMAP.md` 中把 M10 标为完成。 + +## 1. 启动与控制台 + +- [ ] 双击 `启动.bat`,浏览器能打开 `http://127.0.0.1:8898/index.html`。 +- [ ] 启动页正常消失,视口、Ribbon、特征树、属性面板和 AI 面板均可见。 +- [ ] 浏览器开发者工具控制台没有红色异常;允许出现浏览器自身的性能提示。 +- [ ] 前视、上视、右视和等轴测方向与界面名称一致。 + +## 2. AI 一句话轴承座 + +在 AI 面板输入: + +> 生成一个120×80×12的轴承座,主体凸台直径60高32,中心通孔直径30,两条厚4mm加强筋,实体倒角和R4实体圆角 + +验收: + +- [ ] AI 一次执行完成,不需要手工补特征。 +- [ ] 模型可见且没有错误提示,整体尺寸约为 `120×80×43 mm`。 +- [ ] 特征树依次包含:底板、实体倒角、实体圆角、主体凸台、左加强筋、右加强筋、中心通孔。 +- [ ] 参考几何中存在“筋定位基准面”。 +- [ ] 中心通孔使用“到下一面”而不是固定深度。 +- [ ] 模型上能观察到真实斜面倒角和多段圆弧过渡圆角。 + +## 3. 参数编辑与重建 + +- [ ] 右键左加强筋,进入特征参数编辑,把筋宽从 `4` 改为 `5`,确定后模型成功重建。 +- [ ] 编辑实体倒角距离,合法较小值能重建;过大值显示中文失败诊断而不是页面崩溃。 +- [ ] 编辑实体圆角半径或圆弧分段数,合法值能重建;过大半径显示中文失败诊断。 +- [ ] 编辑中心通孔,终止条件下拉框能看到固定深度、贯穿全部、对称、到下一面、到指定面和到实体。 +- [ ] 单击特征树中的新特征时,视口能显示对应高亮。 + +## 4. 实体棱边拾取 + +建议先新建一个简单长方体单独验证: + +- [ ] 点击“实体倒角”,再靠近长方体可见棱边单击,输入 `5` 后成功创建。 +- [ ] 点击“实体圆角”,选择另一条未处理凸直棱,输入 `5` 后成功创建。 +- [ ] 单击远离棱边的位置时,界面提示未拾取到棱边,不创建错误特征。 +- [ ] 对非凸复杂主体或不支持的多边选择,界面明确拒绝,不以光照平滑伪装成功。 + +## 5. 保存、撤销与导出 + +- [ ] 保存轴承座 JSON,刷新或关闭页面后重新打开,模型与特征树一致。 +- [ ] 修改一个新特征后执行撤销、重做,模型和参数能正确往返。 +- [ ] 导出 STL 成功,并能在外部查看器中打开;孔、筋、倒角和圆角均可见。 +- [ ] 打开一个第一阶段以前保存、没有 `references` 字段的旧工程,仍能正常重建。 + +## 6. 演练记录 + +- [ ] 完成一次从启动、AI 输入、参数编辑、保存到 STL 导出的连续课程作业演练。 +- [ ] 记录一段视频或至少四张截图:AI 输入、完整模型、特征树、STL 外部查看结果。 +- [ ] 在演练中没有持续卡顿、页面失去响应或连续重建后明显变慢。 + +## 7. 自动化证据(已完成) + +- 核心综合测试:`94/94` 通过。 +- 七个第一阶段独立模块测试:`93/93` 通过。 +- 总计:`187/187` 通过。 +- AI 轴承座 Node 环境完整重建:约 `20~25ms`,预算 `<1000ms`。 +- 轴承座连续重建 `10` 次体积一致。 +- 轴承座保存/打开体积一致,STL 三角数与网格一致。 +- 旧版无 `references` 字段工程可重建。 + +## 8. 最终签字 + +- [ ] 上述人工项目全部通过。 +- [ ] 将 `ROADMAP.md` 中 M10 改为 `[x]`。 +- [ ] 将路线图当前状态改为“第一阶段完成,下一步进入阶段二 M11”。 + +验收人:____________ +验收日期:____________ +备注:____________________________________________________________ diff --git a/PHASE1_SUBAGENT_TASKS.md b/PHASE1_SUBAGENT_TASKS.md new file mode 100644 index 0000000..4d21f6d --- /dev/null +++ b/PHASE1_SUBAGENT_TASKS.md @@ -0,0 +1,721 @@ +# MiniSW 第一阶段并行子代理任务书 + +版本:v1.0 +日期:2026-08-10 +适用范围:`ROADMAP.md` 的 M02~M10 +当前基线:M01 圆形截面基础扫描已完成,核心测试 51/51 通过 + +## 1. 目标 + +第一阶段的目标是补齐高级零件建模能力,使 MiniSW 能生成包含扫描、放样、筋、参考几何、复杂拉伸终止条件、实体倒角和实体圆角的机械零件,并最终通过“AI 一句话生成轴承座”的综合验收。 + +本任务书用于后续启动多个子代理并行开发。它只规定工作分工、接口、边界和验收方式,不代表现在已经启动子代理。 + +第一阶段包含: + +- M02:路径草图 + 任意封闭截面扫描 +- M03:两个平行等顶点截面的基础放样 +- M04:多截面、异顶点数放样 +- M05:筋特征 +- M06:参考几何 +- M07:`toNext`、`toFace`、`toBody` 终止条件 +- M08:实体棱边倒角 +- M09:实体棱边圆角 +- M10:阶段一综合验收 + +## 2. 协作模型 + +### 2.1 并发限制 + +当前包括主代理在内最多允许 4 个代理同时工作,因此每个批次最多同时运行 3 个子代理。 + +建议分三批执行: + +```text +批次 A(基础几何,可并行) + 子代理 A:扫描完整版 M02 + 子代理 B:放样 M03/M04 + 子代理 D:参考几何 M06 + ↓ 主代理集成与回归 + +批次 B(依赖基础能力,可并行) + 子代理 C:筋 M05 + 子代理 E:终止条件 M07 + 子代理 F:实体倒角/圆角 M08/M09 + ↓ 主代理集成与回归 + +批次 C(验收) + 子代理 G:第一阶段独立测试审查 + 主代理:M10 综合集成、浏览器验收、文档更新 +``` + +### 2.2 主代理是唯一集成者 + +主代理负责所有共享入口和最终决策。除非任务书后续被主代理显式修改,只有主代理可以修改下列共享文件: + +- `js/geometry.js` +- `js/doc.js` +- `js/agent.js` +- `js/main.js` +- `js/tree.js` +- `js/sketchui.js` +- `js/viewport.js` +- `js/math.js` +- `js/sketch.js` +- `index.html` +- `css/style.css` +- `tests/test.mjs` +- `tests/loader.mjs` +- `README.md` +- `ROADMAP.md` + +子代理负责在独立模块和独立测试文件中实现自己的能力,完成后把接口和交接说明提交给主代理,由主代理接入共享文件。 + +### 2.3 建议新增目录 + +为降低并行冲突,第一阶段的新实现优先放在: + +```text +js/features/ + sweep.js + loft.js + rib.js + reference.js + termination.js + edge-treatment.js + +tests/ + test.sweep.mjs + test.loft.mjs + test.rib.mjs + test.reference.mjs + test.termination.mjs + test.edge-treatment.mjs + test.phase1.mjs +``` + +目录和文件由对应子代理创建。其他子代理不得修改不属于自己的模块。 + +## 3. 全体子代理通用规则 + +### 3.1 开始工作前 + +每个子代理必须先完成以下只读检查: + +1. 阅读 `README.md`、`ROADMAP.md` 和本任务书。 +2. 查看 `git status --short`,识别用户和主代理已有改动。 +3. 阅读与自己任务直接相关的现有源码和测试。 +4. 运行当前核心测试,确认基线状态;如果基线已经失败,先报告主代理,不得擅自修复无关问题。 +5. 向主代理确认自己将创建或修改的文件列表,不得扩大范围。 + +### 3.2 文件边界 + +- 多个代理共享同一工作目录,文件修改会立即互相可见;严禁两个代理同时修改同一个文件。 +- 不得恢复或修改当前处于删除状态的 `SUBPROXY_TASKS.md`、`TASK_0_REPORT.md`、`TASK_A_REPORT.md`、`TASK_B_REPORT.md`、`TASK_C_REPORT.md`。 +- 不得修改 `E:\小灰\`、全局记忆或项目外部文件。 +- 不得使用 `git reset --hard`、`git checkout --`、清理工作区或其他会覆盖现有改动的命令。 +- 不得自行提交、推送或创建 PR,除非主人另行授权。 +- 不得重命名、移动或格式化与任务无关的文件。 +- 只使用 `apply_patch` 编辑文件,保留 UTF-8 中文内容。 + +### 3.3 架构约束 + +- 保持原生 JavaScript ESM,不引入打包器。 +- 不新增 CDN 或在线运行时依赖,应用必须继续彻底离线运行。 +- 优先复用仓库内 `vendor/three` 和 `vendor/manifold`。 +- 不得重新引入旧测试路径或外部源码副本;测试必须直接调用当前仓库源码。 +- 新模块使用命名导出,不使用隐式全局状态。 +- 所有长度单位统一为毫米,角度对外使用度数、内部可使用弧度但必须在接口说明中标明。 +- 世界坐标和草图坐标的转换只能走统一接口,不得在多个模块复制不同版本的坐标变换逻辑。 +- 错误必须提供可理解的中文信息,不能只返回 `undefined` 或底层 WASM 状态码。 + +### 3.4 几何内核生命周期 + +- 谁创建 manifold/CrossSection 等 WASM 对象,谁负责释放;所有权转移必须写在函数注释中。 +- 临时对象必须使用 `try/finally` 或等价结构释放。 +- 返回 manifold 的函数必须明确写明“返回值所有权交给调用方”。 +- 不得在失败路径遗留未释放对象。 +- 重复重建测试至少执行 10 次,并检查体积、包围盒和错误状态稳定。 + +### 3.5 测试要求 + +每个子代理必须提供独立测试文件,至少覆盖: + +- 正常路径 +- 参数边界 +- 退化输入 +- 非法引用 +- 保存/打开需要保留的纯数据 +- 重复执行稳定性 +- 中文错误信息 + +独立测试通过不等于任务完成。只有主代理完成共享入口接入并通过总回归后,里程碑才能在 `ROADMAP.md` 中勾选。 + +### 3.6 性能预算 + +- 普通拉伸、筋、参考几何解析:目标小于 200ms。 +- 扫描、放样、实体圆角/倒角:目标小于 1s。 +- 测试必须记录至少一个典型模型的运行时间。 +- 网格过密时应给出降采样建议,不得无上限增加路径和截面分段数。 + +### 3.7 子代理交付报告格式 + +每个子代理完成后必须向主代理报告: + +1. 完成的能力。 +2. 新增和修改的文件。 +3. 导出接口及参数语义。 +4. 对象所有权和释放规则。 +5. 测试命令、通过数和耗时。 +6. 已知限制。 +7. 需要主代理接入共享文件的具体位置。 +8. 是否存在与其他代理的接口冲突。 + +## 4. 公共数据与接口契约 + +子代理不得自行改变以下公共概念;如确需调整,先向主代理提交接口变更申请。 + +### 4.1 MeshData + +```js +{ + verts: Float32Array, // xyz 连续排列 + tris: Uint32Array, // 三角形顶点索引 +} +``` + +要求:三角面朝外、无退化三角形、封闭实体满足二维流形条件。 + +### 4.2 PathData + +```js +{ + points: [[x, y, z], ...], + closed: false, + sourceSketchId: number | null, +} +``` + +路径至少包含两个不同点。采样点去重容差统一为 `1e-6 mm`。 + +### 4.3 ProfileData + +```js +{ + points: [[u, v], ...], + closed: true, + plane: { + origin: [x, y, z], + xAxis: [x, y, z], + yAxis: [x, y, z], + normal: [x, y, z], + }, + sourceSketchId: number | null, +} +``` + +轮廓不得包含重复末点;闭合由 `closed` 表达。外环和孔环需要明确分组,禁止依赖不稳定的顺逆时针猜测。 + +### 4.4 ReferenceData + +参考几何使用稳定 ID,不直接保存运行时对象: + +```js +{ id, type: 'plane', name, origin, normal, xAxis, sourceRefs, params } +{ id, type: 'axis', name, origin, direction, sourceRefs, params } +{ id, type: 'point', name, position, sourceRefs, params } +{ id, type: 'csys', name, origin, xAxis, yAxis, zAxis, sourceRefs, params } +``` + +### 4.5 特征构建结果 + +纯网格算法优先返回 `MeshData`。需要 manifold 布尔运算的模块通过主代理提供的内核上下文工作,不得自行初始化第二套 WASM 模块。 + +接口成功时返回结果,失败时抛出 `Error`。不得混用“返回 null”和“抛异常”两种失败协议。 + +## 5. 子代理 A:扫描完整版(M02) + +### 5.1 目标 + +把当前“世界坐标离散路径 + 圆形截面”扫描扩展为“路径草图 + 任意封闭截面草图”,并支持可控的截面方向和扭转。 + +### 5.2 允许修改 + +- 新建并修改 `js/features/sweep.js` +- 新建并修改 `tests/test.sweep.mjs` +- 如需测试夹具,可新建 `tests/fixtures/sweep/` 下的 JSON 文件 + +### 5.3 禁止修改 + +- 所有第 2.2 节列出的共享文件 +- `js/features/loft.js` 及其他子代理文件 + +### 5.4 具体任务 + +1. 从路径草图的直线、圆弧和连续多段线生成有序三维采样点。 +2. 从封闭截面草图提取一个或多个轮廓环。 +3. 支持圆形、矩形和一般多边形截面。 +4. 使用平行传递标架,避免低曲率处的 Frenet 标架翻转。 +5. 支持 `frameMode: 'parallelTransport' | 'fixed'`。 +6. 支持 `twistDegrees`,沿路径累计旋转截面。 +7. 支持起点/终点封端。 +8. 检测零长度路径、路径断裂、开放截面、截面退化、急弯自交和截面尺寸过大。 +9. 保持当前 M01 圆形截面扫描结果兼容。 + +### 5.5 建议导出接口 + +```js +export function samplePathSketch(sketch, options = {}) +export function profileFromSketch(sketch, options = {}) +export function buildSweepMesh({ path, profile, frameMode, twistDegrees, capStart, capEnd, segments }) +``` + +### 5.6 最低测试集 + +- 圆形截面沿直线扫描 +- 矩形截面沿直线扫描 +- 圆形截面沿四分之一圆弧扫描 +- 矩形截面沿多段线路径扫描 +- 固定方向与平行传递结果不同但均有效 +- 90° 扭转后的终点截面方向正确 +- 开放截面被拒绝 +- 路径只有一个点被拒绝 +- 急弯导致自交时给出明确错误或降采样提示 +- 生成网格满足流形要求 + +### 5.7 验收标准 + +- 独立测试全部通过。 +- 直线、圆弧、多段线三类路径均可工作。 +- 圆形、矩形、一般封闭多边形三类截面均可工作。 +- 典型模型构建小于 1s。 +- 向主代理提供从现有 `Doc` 草图引用转换到公共 `PathData/ProfileData` 的接入说明。 + +## 6. 子代理 B:放样(M03/M04) + +### 6.1 目标 + +实现两个及多个封闭截面间的实体放样,解决顶点配对、异顶点数重采样和截面起点对齐问题。 + +### 6.2 允许修改 + +- 新建并修改 `js/features/loft.js` +- 新建并修改 `tests/test.loft.mjs` +- 如需测试夹具,可新建 `tests/fixtures/loft/` + +### 6.3 禁止修改 + +- 所有共享文件 +- 扫描、筋、参考几何等其他代理模块 + +### 6.4 具体任务 + +1. M03:两个平行、等顶点数封闭截面的侧壁连接和端面封闭。 +2. 自动统一截面环方向。 +3. 自动选择截面起始点,使相邻截面连线总长度尽量小。 +4. M04:支持三个及以上截面。 +5. 支持异顶点数截面的均匀弧长重采样。 +6. 支持矩形到圆形、圆形到多边形的过渡。 +7. 检测截面顺序错误、共面重复截面、面积趋近于零、自交和生成体翻转。 +8. 为后续切除放样保留同一网格接口,不在本任务中改文档布尔语义。 + +### 6.5 建议导出接口 + +```js +export function resampleClosedProfile(points, count) +export function alignProfileStart(reference, candidate) +export function buildLoftMesh({ profiles, resampleCount, capStart, capEnd, alignment }) +``` + +### 6.6 最低测试集 + +- 等顶点矩形到矩形 +- 等顶点圆到圆 +- 异顶点矩形到圆形 +- 三截面方—圆—方过渡 +- 截面输入顺序反向时自动修正 +- 两个截面重合时报错 +- 退化截面时报错 +- 网格封闭且体积大于零 +- 保存所需的截面引用参数可以 JSON 序列化 + +### 6.7 验收标准 + +- M03 与 M04 测试分别报告,不得用“基础版完成”代替完整版。 +- 至少通过矩形到圆形的异顶点放样。 +- 输出为稳定、封闭的 `MeshData`。 +- 典型三截面放样小于 1s。 + +## 7. 子代理 C:筋特征(M05) + +### 7.1 依赖 + +在批次 A 集成完成后启动。依赖稳定的草图平面转换、拉伸工具和布尔并集接口。 + +### 7.2 允许修改 + +- 新建并修改 `js/features/rib.js` +- 新建并修改 `tests/test.rib.mjs` + +### 7.3 禁止修改 + +- 所有共享文件 +- 扫描、放样和参考几何模块 + +### 7.4 具体任务 + +1. 接受一条或多条连续开放轮廓作为筋中心线。 +2. 支持厚度、单侧/双侧、翻转方向和拔模角参数。 +3. 将中心线扩展为薄壁封闭轮廓。 +4. 沿草图平面法向拉伸到目标实体范围。 +5. 与已有实体求交/并集,确保筋只保留与主体相连的部分。 +6. 检测筋不接触主体、厚度过大、轮廓自交、路径不连续和零长度线段。 + +### 7.5 建议导出接口 + +```js +export function offsetOpenPath(points, thickness, side) +export function buildRibProfile({ path, thickness, side, draftDegrees }) +export function buildRibTool(ctx, { sketch, thickness, side, flip, extent, body }) +``` + +其中 `ctx` 由主代理注入几何内核能力,子代理不得创建第二个 manifold 实例。 + +### 7.6 最低测试集 + +- 底板与侧板之间单条直筋 +- 双侧对称筋 +- 翻转方向 +- 折线筋 +- 筋不接触主体时报错 +- 厚度过大时报错 +- 重复重建体积稳定 + +### 7.7 验收标准 + +- “底板 + 侧板 + 厚 3mm 筋板”端到端几何有效。 +- 筋与主体形成一个连通实体。 +- 普通筋特征构建小于 200ms。 + +## 8. 子代理 D:参考几何(M06) + +### 8.1 目标 + +提供稳定、可序列化、可重建的基准面、基准轴、基准点和坐标系计算模块。 + +### 8.2 允许修改 + +- 新建并修改 `js/features/reference.js` +- 新建并修改 `tests/test.reference.mjs` + +### 8.3 禁止修改 + +- `doc.js`、`math.js`、UI 和其他共享文件;数据接入由主代理完成 + +### 8.4 具体任务 + +1. 偏移基准面:相对已有平面沿法向偏移指定距离。 +2. 角度基准面:绕指定轴旋转指定角度。 +3. 平行于指定面并经过指定点的基准面。 +4. 两点基准轴。 +5. 两平面交线基准轴。 +6. 显式坐标基准点和引用实体中心点。 +7. 用户坐标系:原点和正交三轴。 +8. 验证法向、方向向量归一化和右手坐标系。 +9. 检测平行平面无交线、重合点、零方向向量、循环引用和丢失引用。 + +### 8.5 建议导出接口 + +```js +export function makeOffsetPlane(basePlane, distance) +export function makeAngledPlane(basePlane, axis, degrees) +export function makePlaneThroughPoint(basePlane, point) +export function makeAxisFromPoints(a, b) +export function makeAxisFromPlanes(a, b) +export function makeCoordinateSystem(origin, xHint, yHint) +export function resolveReference(reference, lookup) +``` + +### 8.6 最低测试集 + +- 前视面偏移 10mm +- 绕基准轴旋转 30° +- 两垂直平面求交轴 +- 平行平面求交被拒绝 +- 两个相同点不能建立轴 +- 坐标系满足单位长度、互相垂直和右手性 +- JSON 往返后计算结果一致 +- 循环引用被检测 + +### 8.7 验收标准 + +- 所有参考几何均为纯数据,可 JSON 序列化。 +- 计算结果具有稳定 ID 引用,不依赖数组下标。 +- 主代理可据此在偏移基准面上创建草图并拉伸。 + +## 9. 子代理 E:拉伸终止条件(M07) + +### 9.1 依赖 + +在参考几何和稳定拓扑引用完成初步集成后启动。 + +### 9.2 允许修改 + +- 新建并修改 `js/features/termination.js` +- 新建并修改 `tests/test.termination.mjs` + +### 9.3 禁止修改 + +- 现有 `buildExtrude`、文档模型和 UI;接入由主代理完成 + +### 9.4 具体任务 + +1. 统一终止模式:`blind`、`throughAll`、`symmetric`、`toNext`、`toFace`、`toBody`。 +2. `toNext`:沿拉伸方向找到第一个有效实体交点。 +3. `toFace`:根据稳定面引用解析目标面,并计算有效深度。 +4. `toBody`:生成足够长工具体后与目标实体裁剪。 +5. 支持正向、反向和偏置距离。 +6. 检测目标位于反方向、目标不存在、没有交点、深度趋近于零和引用失效。 +7. 不允许用固定超大深度冒充 `toFace` 的最终结果;近似方案必须明确标注并有误差测试。 + +### 9.5 建议导出接口 + +```js +export function normalizeTermination(params) +export function resolveToNext({ origin, direction, body, tolerance }) +export function resolveToFace({ origin, direction, targetFace, offset, tolerance }) +export function buildToBodyTool(ctx, { profileTool, targetBody, direction }) +``` + +### 9.6 最低测试集 + +- 两层实体间 `toNext` +- 到平面 `toFace` +- 到倾斜面 `toFace` +- 到独立实体 `toBody` +- 翻转方向 +- 目标在反方向时报错 +- 丢失引用时报错 +- 重复重建结果稳定 + +### 9.7 验收标准 + +- 三种新增模式均有独立测试和文档参数示例。 +- 结果不能越过目标面或目标实体。 +- 普通终止条件解析小于 200ms。 + +## 10. 子代理 F:实体倒角与圆角(M08/M09) + +### 10.1 目标 + +建立实体拓扑边识别和稳定引用,在此基础上实现真正作用于三维棱边的倒角与圆角。草图圆角矩形不能作为本任务的替代品。 + +### 10.2 允许修改 + +- 新建并修改 `js/features/edge-treatment.js` +- 新建并修改 `tests/test.edge-treatment.mjs` +- 可新建 `tests/fixtures/edge-treatment/` + +### 10.3 禁止修改 + +- `geometry.js`、`doc.js`、`viewport.js`、`tree.js` 和 AI 文件 +- 不得把已有 `chamfer_rect`、`fillet_rect` 政名后宣称实体功能完成 + +### 10.4 执行顺序 + +该任务必须按三个检查点推进: + +1. **F0 内核能力探测**:确认当前 manifold 版本能提供哪些面、边、切片、分解、平滑或网格操作;形成能力报告。 +2. **F1 实体倒角**:先完成凸多面体的一条直棱边倒角,再扩展到多边选择。 +3. **F2 实体圆角**:先完成立方体一条直棱边的恒定半径圆角,再扩展到共线边链。 + +如果 F0 证明内核无法安全提供精确圆角所需能力,必须报告技术阻塞和可验证替代方案,不能提交会产生非流形网格的视觉伪实现。 + +### 10.5 具体任务 + +1. 从三角网格合并共面三角形,提取拓扑面和拓扑边。 +2. 为边生成稳定引用:相邻面特征、端点量化坐标和几何签名组合,禁止只保存三角形索引。 +3. 倒角支持距离参数和单边/多边选择。 +4. 圆角支持半径参数、圆弧分段数和相切边链。 +5. 检测距离/半径过大、边不存在、凹凸方向不支持、拓扑引用失效和处理后非流形。 +6. 输出选中边、高亮所需的线段数据,供主代理接入视口拾取。 + +### 10.6 建议导出接口 + +```js +export function extractTopology(mesh, tolerance) +export function makeStableEdgeRef(edge, topology) +export function resolveEdgeRef(ref, topology) +export function buildChamferMesh({ mesh, edgeRefs, distance, tolerance }) +export function buildFilletMesh({ mesh, edgeRefs, radius, segments, tolerance }) +``` + +### 10.7 最低测试集 + +- 立方体单边倒角 5mm +- 立方体四条平行边倒角 +- 倒角距离过大被拒绝 +- 立方体单边圆角 R5 +- 共线边链圆角 +- 圆角半径过大被拒绝 +- 保存/打开后稳定边引用仍能解析 +- 上游尺寸改变后引用能重定位或明确报失效 +- 输出网格封闭、体积合理、无退化三角形 +- 连续 10 次重建稳定 + +### 10.8 验收标准 + +- 放大观察可见真实三维斜面或圆弧过渡。 +- STL 输出包含处理后的真实几何,而非仅渲染效果。 +- 单边处理小于 1s。 +- M08 和 M09 分开报告测试结果。 + +## 11. 子代理 G:第一阶段独立测试审查 + +### 11.1 启动时机 + +仅在 A~F 的成果全部由主代理接入后启动。该代理不得参与功能实现,以保持审查独立性。 + +### 11.2 允许修改 + +- 新建并修改 `tests/test.phase1.mjs` +- 新建 `PHASE1_QA_REPORT.md` + +### 11.3 禁止修改 + +- 所有生产源码 +- 其他代理测试文件 +- README 和 ROADMAP + +### 11.4 审查内容 + +1. 检查 M01~M09 每项是否有正常、边界和错误测试。 +2. 检查 DSL、文档模型、几何构建和 UI 参数是否语义一致。 +3. 检查保存/打开后的全部新特征能否重建。 +4. 检查撤销/重做是否保留新参数。 +5. 连续重建组合模型,观察体积、包围盒和错误状态。 +6. 检查 WASM 临时对象释放路径。 +7. 测量扫描、放样、筋、终止条件、倒角、圆角耗时。 +8. 测试旧 51 条基线断言无回归。 +9. 为“轴承座”综合模型准备确定性数据夹具。 + +### 11.5 验收输出 + +QA 报告必须把问题分成: + +- 阻断 M10 +- 可在第一阶段收尾修复 +- 可延期到第二阶段 + +不得只写“全部正常”,每项结论必须附测试证据。 + +## 12. 主代理集成职责 + +主代理不把共享入口交给子代理并行修改。每一批完成后,主代理按以下顺序集成: + +1. 审查子代理文件边界和测试结果。 +2. 统一公共数据格式和错误协议。 +3. 接入 `geometry.js` 的内核包装和对象生命周期。 +4. 接入 `doc.js` 的特征类型、重建分支、稳定引用和序列化。 +5. 接入 `agent.js` 的 DSL、规则解析、校验与执行。 +6. 接入 `tree.js`、`main.js`、`viewport.js` 的名称、属性表单、拾取和高亮。 +7. 更新统一测试入口。 +8. 跑全部测试并修复集成问题。 +9. 只有总回归通过后才更新 README 和 ROADMAP 勾选状态。 + +主代理必须特别检查: + +- 子代理是否越界修改共享文件。 +- 是否出现同一概念的多个不兼容数据结构。 +- 是否初始化了多个 manifold WASM 实例。 +- 是否在失败路径泄漏对象。 +- 是否把近似功能错误描述为精确功能。 +- 是否破坏旧工程文件兼容性。 + +## 13. M10 综合验收 + +### 13.1 轴承座模型 + +最低包含: + +- 底板 +- 主体凸台 +- 中心通孔 +- 至少两条筋 +- 至少一个参考基准面 +- 至少一个非固定深度终止条件 +- 实体倒角 +- 实体圆角 +- 可选扫描或放样结构 + +### 13.2 AI 验收句 + +最终应能用一条中文描述生成完整模型。AI 输出允许分解为多个底层操作,但不得依赖手工补特征。 + +### 13.3 功能验收 + +- 特征树顺序正确,所有特征无错误。 +- 每个新特征可编辑参数并重建。 +- 撤销/重做有效。 +- 保存、关闭、重新打开后结果一致。 +- STL 导出有效。 +- 旧工程文件仍能打开。 + +### 13.4 质量验收 + +- 第一阶段模块测试全部通过。 +- 原有 51 条测试无回归。 +- 不存在已知 WASM 对象泄漏路径。 +- 单个普通特征满足性能预算。 +- 浏览器控制台无新增错误。 +- 完成一次浏览器课程作业演练并记录结果。 + +### 13.5 路线图完成条件 + +只有以上条件全部满足,才能在 `ROADMAP.md` 中将 M10 标记为完成,并宣布第一阶段结束。 + +## 14. 冲突与异常处理 + +### 14.1 文件冲突 + +发现其他代理正在修改自己的目标文件时: + +1. 立即停止写入。 +2. 保留当前分析结果,不覆盖对方改动。 +3. 向主代理报告冲突文件和双方任务。 +4. 由主代理重新划分边界后再继续。 + +### 14.2 接口冲突 + +需要改变公共数据结构或共享函数签名时,子代理只提交一份接口申请,包含: + +- 现有接口为什么不足 +- 建议的新签名 +- 调用方影响 +- 向后兼容方案 +- 所需测试 + +未经主代理批准,不得自行修改共享接口。 + +### 14.3 技术阻塞 + +同一阻塞连续验证三次仍无法解决时,提交最小复现、底层错误、尝试过的方案和建议替代路径。不得用空实现、静默跳过或仅视觉效果掩盖阻塞。 + +### 14.4 资源异常 + +发现测试、浏览器自动化或几何构建出现异常 token、内存或时间消耗时立即暂停,向主人说明规模、原因和更省资源的方案;未经确认不继续高消耗流程。 + +## 15. 第一阶段完成后的交付物 + +第一阶段结束时应具备: + +- M02~M09 的生产实现 +- 模块化测试文件和统一回归入口 +- 更新后的 DSL 说明 +- 更新后的文件格式版本和兼容逻辑 +- 第一阶段 QA 报告 +- 轴承座课程作业演练记录 +- README 功能说明 +- ROADMAP 完成标记和遗留问题清单 + +本任务书是第一阶段并行开发的边界依据。实际启动子代理时,主代理应从对应章节复制任务,并额外告知当时的最新 Git 状态和已完成依赖。 diff --git a/README.md b/README.md index 308a74c..751333b 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,33 @@ ## 功能特性 -- **参数化建模**:草图(直线 / 矩形 / 圆 / 圆弧 / 中心线)→ 特征(拉伸 / 切除 / 旋转 / 圆柱 / 圆柱切除)→ 重建,特征树可回看编辑 -- **AI 智能体**:中文自然语言描述零件,自动解析为建模操作并执行;离线可用(内置规则解析器),也可配置 LLM 走深度推理 +- **参数化建模**:草图(直线 / 矩形 / 圆 / 圆弧 / 椭圆 / 长圆槽 / 样条 / 中心线)→ 特征(拉伸 / 切除 / 旋转 / 圆柱 / 扫描 / 放样 / 筋 / 实体倒角 / 实体圆角)→ 重建,特征树可回看编辑 +- **参数化草图**:水平、垂直、重合、相等、平行、垂直、相切、角度、半径等约束;命名尺寸/参数表达式;草图线性和圆周阵列 +- **装配体工作区**:组件树、零件插入、同心/重合/距离等基础配合、包围盒与实体布尔干涉检查、爆炸视图 +- **工程图工作区**:A3/A4 图框、第一角三视图、尺寸与基础公差、关联剖视图、标题栏,以及 SVG/PNG 导出 +- **AI 智能体**:中文自然语言描述零件,自动解析为建模操作并执行;支持手动切换“内置解析器 / LLM 智能体”,并支持新建、追加、查询、修改、删除;它还能读取实体尺寸、体积、网格统计、特征增量和错误状态进行几何自观察;遇到依赖顺序错误会按“建模→装配→工程图”自动重排,重建失败时会诊断并自动移除不可用操作后重试 +- **严格模式路由**:选择 LLM 后,查询、新建、追加、修改、删除、装配和工程图全部先由 LLM 理解;本地只校验并执行带稳定特征 ID 的 `query_document`、`update_feature`、`delete_feature` 等操作,不再提前用本地 CRUD 截获,也不静默降级 +- **LLM 质量优先流程**:设置中可选“快速模式/专家完整模式”;快速模式保留为用户手动选择的低延迟方案,专家模式不再为节省 token 跳过阶段,查询、新建、追加、修改、删除和建模都会依次经过顶级机械工程师预审、完整意图规范化与 DSL 规划。第一阶段机械工程师只输出完整的 Markdown 工程架构说明(工程目标、对象与场景、坐标基准、几何组成与特征顺序、尺寸与默认假设、几何约束、装配意图、工程图意图、风险项),不被 JSON 格式束缚;第二阶段再把这份 Markdown 转为规范化意图 JSON,第三阶段才生成 `{"ops":[...]}` DSL。每一层都允许模型充分内部推理,不向请求注入 `max_tokens` 输出上限;执行纠错与视觉复核仍分别严格限定为 JSON。生成并重建成功后,MiniSW 本地几何观察器会把模型位置、尺寸、网格、特征树和实体完整性反馈给机械制图专家,再进行一次生成后视觉复核;格式、计划或执行失败时会把真实错误回传模型继续纠错。界面会显示 API 调用次数、总耗时、当前阶段和视觉复核结论 +- **Agent 调试窗口**:AI 面板顶部可打开调试窗口,以 OpenAI/DeepSeek 兼容 SSE 流式方式实时查看模型增量输出,并查看每次请求的阶段、请求消息、原始响应、DSL 校验、文档预检、执行错误、视觉复核和纠错记录;支持日志内标准 `Ctrl+C`,以及 `Ctrl+Shift+C` 复制全部调试文本 - **高级宏指令**:倒角矩形、圆角矩形、多边形、抽壳、镜像、线性阵列、圆周阵列、凸台等一键生成 - **文档能力**:保存 / 打开(JSON)、导出 STL、撤销 / 重做 -- **SolidWorks 风格界面**:可拖拽调整的 Ribbon / 特征树 / 属性面板,可移动的 AI 浮动面板,四向视图切换 +- **参考几何**:偏移/角度/过点基准面、基准轴、基准点和用户坐标系,草图可挂载到自定义参考面 +- **拉伸终止条件**:固定深度、贯穿全部、对称、到下一面、到指定面和到实体;目标面引用随文档保存 +- **实体边处理**:视口直接拾取凸直棱;倒角用真实网格截切,圆角用多段圆柱过渡面,均通过 manifold 内核闭合验证 +- **SolidWorks 风格界面**:可拖拽调整的 Ribbon / 特征树 / 属性面板,可移动的 AI 浮动面板,四向视图切换;首次生成或打开实体时按包围盒自动取景,保持真实 mm 工程尺寸,不用“缩放模型”冒充适配视口 ## 快速开始 ### 方式一:双击启动脚本(推荐) -双击 `启动.bat`,脚本会自动启动本地服务器并打开浏览器,访问 `http://127.0.0.1:8898/index.html`。关闭黑色命令行窗口即停止服务器。 +双击当前项目目录中的 `启动.bat`,脚本会从脚本所在目录启动本地服务器并打开浏览器。默认端口为 `8898`;如果该端口已被旧服务器占用,会自动选择下一个空闲端口。关闭黑色命令行窗口即停止服务器。 > 注意:不要直接双击 `index.html` 用 `file://` 协议打开——ES Module 和 WASM 会被浏览器安全策略拦截,页面会卡在加载页。 ### 方式二:手动启动 ```bash -cd F:\Program Files\MiniSW +cd E:\MiniSW python -m http.server 8898 ``` @@ -45,6 +54,11 @@ python -m http.server 8898 | 输入 | 生成结果 | |---|---| | 做一个 100×100×20 的方块,中心开一个半径15的圆孔 | 底板 + 中心通孔 | +| 查询当前模型有哪些特征和尺寸 | 返回实体尺寸、草图和特征清单 | +| 把加强筋厚度改为6mm | 定位加强筋并全树重建 | +| 删除最后一个特征 | 删除末尾特征,并清理未使用草图 | +| 在当前零件上添加一个直径20mm的通孔 | 仅追加孔草图与切除,不重建底板 | +| 你能看到什么?请描述当前模型 | 返回实体状态、尺寸、体积、网格和特征观察结果 | | 空心圆柱,外径60,内径40,高50 | 空心圆柱 | | 做一个 200×120×10 的板,中间开一个 60×40 的矩形槽,四角打 4 个直径6的孔 | 槽板 + 四角孔 | | 做一个 100×80×20 的矩形板,四边倒角 5mm | 倒角板 | @@ -53,6 +67,13 @@ python -m http.server 8898 | 做一个直径120厚10的圆盘,打一个直径10的孔距离中心40,圆周阵列6个 | 圆周阵列孔 | | 做一个 100×80×10 的底板,顶面中心加一个直径30高20的圆柱凸台 | 底板 + 凸台 | | 做一个 100×60×10 的板,左侧凸台镜像到右侧 | 镜像特征 | +| 沿半径50的四分之一圆弧扫描直径8的圆形截面 | 90° 弯管实体 | +| 从40×30的矩形放样到直径20的圆形,高度50 | 矩形—圆形过渡体 | +| 创建距离前视面10mm的偏移基准面 | 偏移参考面 | +| 做一个100×60×10的底板,加一条厚3mm、筋高30mm的加强筋 | 底板 + 加强筋 | +| 做一个40×30×20的方块,给一条实体棱边倒角5mm | 单棱实体倒角 | +| 做一个40×30×20的方块,给一条实体棱边做R5圆角 | 多段实体圆角 | +| 生成一个120×80×12的轴承座,主体凸台直径60高32,中心通孔直径30,两条厚4mm加强筋,实体倒角和R4实体圆角 | 第一阶段综合轴承座 | ## 项目结构 @@ -63,6 +84,8 @@ MiniSW/ ├── css/ │ └── style.css # 全部样式(含可拖拽面板的 resizer) ├── js/ +│ ├── features/ # 扫描、放样、筋、参考几何、终止条件、倒角/圆角、装配、工程图 +│ ├── constraints.js # 轻量 2D 约束求解与参数表达式 │ ├── main.js # 应用主控:Ribbon、面板交互、事件绑定 │ ├── doc.js # 零件文档模型:草图/特征/重建/序列化/撤销 │ ├── geometry.js # manifold 几何内核封装:布尔/拉伸/旋转/圆柱 @@ -88,13 +111,13 @@ MiniSW/ node --loader ./tests/loader.mjs ./tests/test.mjs ``` -测试覆盖:WASM 加载、底板 + 四角孔手动建模、包围盒校验、STL 导出、旋转特征、AI 自然语言解析等。 +测试覆盖:WASM 加载、底板 + 四角孔手动建模、包围盒校验、STL 导出、旋转、扫描、放样、筋、参考几何、六种拉伸终止条件、实体倒角/圆角与 AI 一句话轴承座等。 ### 架构说明 - **几何内核**:manifold-3d WASM。`geometry.js` 统一封装实体构造(`buildExtrude` / `buildRevolve` / `buildCylinder`)与布尔运算(`booleanOp`),并负责 WASM 对象生命周期管理(`delete()`) - **文档模型**:`doc.js` 的 `Doc` 类持有草图与特征的有序列表,`rebuild()` 按顺序重建实体,支持增量网格(`deltaMeshes`,用于特征高亮) -- **AI 链路**:`agent.js` 先做中文规则匹配生成操作序列(ops),再经宏展开层(`expandOps`)扩展为底层操作,最终由 `executeOps` 写入文档并重建;LLM 模式则把 ops 交给模型规划 +- **AI 链路**:`agent.js` 先判断自然语言意图(新建 / 追加 / 查询 / 修改 / 删除),再生成操作序列或文档 CRUD 请求;`inspectDocument` 汇总实体状态、尺寸、体积、网格和特征增量作为自观察;几何请求经宏展开层(`expandOps`)交给 `executeOps`,重建出现特征错误时会回滚并自动修正一次;LLM 模式则把几何 ops 与观察上下文交给模型规划 - **坐标系统**:草图平面(前视 / 上视 / 右视)通过 `math.js` 的 `toWorld` 映射到世界系,拉伸方向由平面法向决定 ### 依赖本地化 @@ -104,14 +127,21 @@ node --loader ./tests/loader.mjs ./tests/test.mjs ## 已知限制 - 圆形分段数固定为 96(`js/sketch.js`),已满足绝大多数教学场景;极端大半径圆弧如需更光滑可调高该值(会相应增加面数) -- 目前仅支持单一实体(多体、装配、工程图在规划中,见 `ROADMAP.md`) -- 约束系统为数据结构层面,尚无数值求解器(尺寸标注暂不驱动几何) +- 装配体当前以当前文档实体作为组件来源,配合求解以轻量平移约束为主;跨文件零件库、完整旋转自由度和高级动画仍在后续路线图中 +- 扫描支持路径草图、单环任意封闭截面、固定/平行传递标架与扭转;带孔多环截面目前不支持自动封端 +- 放样支持平行单环多截面和异顶点数重采样;带孔截面及非平行截面尚未支持 +- `toBody` 的独立几何模块支持显式目标实体;当前单实体文档中的稳定目标名为 `current`,跨独立实体引用将在多实体文档阶段开放 +- 实体倒角/圆角当前只接受方向一致、封闭凸实体上的一条凸直棱;多边选择、边链、凹边和非凸体会明确报错。圆角是可导出 STL 的多段圆柱面近似,不是精确 NURBS/B-rep 圆角 +- 约束求解器为可解释的轻量 2D 迭代实现,支持常用教学约束与欠/过约束报告;尚不等同于工业级全局求解器 +- 工程图轮廓来自网格棱边投影,剖视采用三角形裁剪与剖面线;复杂曲面隐藏线和精确 B-rep 标注仍可继续增强 - LLM 模式需自行配置 API 地址与密钥(AI 面板 ⚙ 设置) ## 路线图 进阶规划(扫描 / 放样 / 筋 / 实体圆角、约束求解器与尺寸驱动、装配体、工程图)详见 [ROADMAP.md](ROADMAP.md)。 +第一阶段的最终浏览器与课程演练步骤见 [PHASE1_M10_MANUAL_CHECKLIST.md](PHASE1_M10_MANUAL_CHECKLIST.md)。 + ## 许可与声明 本项目为教学演示性质,仅用于学习与实验用途。SolidWorks 为 Dassault Systèmes 的注册商标,本项目与其无任何关联。 diff --git a/ROADMAP.md b/ROADMAP.md index 5e966be..428f78e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,65 @@ 版本:v1.0 · 日期:2026-08-10 · 编写:总工程师(审阅视角) +## 当前实施进度(2026-08-10) + +- **M01~M09 已完成生产接入。** 扫描、放样、参考几何、筋、六种拉伸终止模式、真实网格实体倒角和多段圆柱面实体圆角均已接入文档、AI DSL、保存/打开和界面参数编辑;倒角/圆角支持视口拾取与稳定边引用。 +- 测试直接调用当前仓库的 `js/` 与 `vendor/`,不依赖旧外部源码副本;当前核心综合测试为 **94/94 通过**,七个第一阶段独立模块测试为 **93/93 通过**,合计 **187/187**。 +- M10 自动化部分已通过,最终浏览器演练由用户按 [`PHASE1_M10_MANUAL_CHECKLIST.md`](PHASE1_M10_MANUAL_CHECKLIST.md) 手动确认。实体倒角/圆角当前采用经过内核验证的保守范围:封闭凸实体的一条直棱;多边链、凹边和非凸体会明确拒绝,不以平滑伪装成功。 +- **阶段二至阶段四核心闭环已完成(2026-08-10)**:参数化草图、装配体、工程图均已接入文档模型、AI DSL、保存/打开和界面工作区;工程图支持 SVG/PNG 导出,装配体支持组件树、基础配合、干涉检查和爆炸视图。 +- 阶段二至四自动化验收:`test.phase2.mjs` 8/8、`test.assembly.mjs` 5/5、`test.drawing.mjs` 4/4、`test.phase34.mjs` 16/16;Agent 质量优先多轮编排(第一阶段 Markdown 工程架构 → 意图 JSON → DSL)、结构化 JSON、兼容接口降级、参数化直齿轮编译、模糊对象默认工程假设、视觉反馈复核、DeepSeek/OpenAI 兼容 SSE 流式调试、DSL 容错与增删改查回归 `test.agent-crud.mjs` 42/42、核心 `test.mjs` 94/94,合计 **169/169**(不含阶段一独立报告中的历史计数)。 + +### 阶段二至四交付边界 + +- 约束求解器是可解释的轻量 2D 迭代求解器,支持欠约束/完全约束/过约束报告;当前不承诺工业级全局最优求解。 +- 装配体支持当前文档实体插入、稳定组件引用、基础平移配合、包围盒预筛与可用时的实体布尔复核;高级旋转自由度和跨文件引用仍属于后续增强项。 +- 工程图使用网格棱边投影生成矢量视图,剖视使用三角形裁剪与剖面线;已满足课程作业交付所需的三视图、标注、标题栏和 SVG/PNG 输出,复杂曲面隐藏线算法仍可继续增强。 + +## 可执行里程碑拆分 + +执行规则:一次只推进一个里程碑。每个里程碑必须完成核心实现、文档模型接入、AI DSL/规则解析、保存/打开兼容、错误提示、自动化测试和本文档进度更新,才可以勾选完成。浏览器录像集中在每个阶段的综合验收任务中进行,避免重复消耗资源。 + +### 阶段一:高级零件建模 + +- [x] **M01 圆形截面基础扫描**:圆形截面沿直线、离散三维路径和四分之一圆弧扫描;支持封端、AI 指令和保存/打开。当前核心测试 51/51 通过。 +- [x] **M02 扫描完整版**:支持路径草图、任意封闭截面草图、截面与路径引用、方向控制和扭转控制。 +- [x] **M03 放样基础版**:支持两个平行、等顶点数的封闭截面放样,完成基本顶点配对和端面封闭。 +- [x] **M04 放样完整版**:支持三个及以上截面、异顶点数均匀重采样、截面起点对齐和自交诊断。 +- [x] **M05 筋特征**:支持开放轮廓、厚度、双侧/单侧方向、延伸到相邻实体并自动合并。 +- [x] **M06 参考几何**:增加偏移/角度基准面、两点/两平面基准轴、基准点与坐标系,并允许草图引用。 +- [x] **M07 拉伸终止条件**:为拉伸和切除增加 `toNext`、`toFace`、`toBody`,保留现有 `blind`、`throughAll` 和 `symmetric`。 +- [x] **M08 实体棱边倒角**:建立拓扑边引用与拾取,支持距离参数、特征重建、AI 指令和失败诊断;当前安全范围为凸实体单条直棱。 +- [x] **M09 实体棱边圆角**:支持半径参数、多段真实圆弧过渡、拓扑边引用、特征重建、AI 指令和失败诊断;当前安全范围为凸实体单条直棱。 +- [ ] **M10 阶段一综合验收**:用 AI 一句话生成轴承座(底板、凸台、通孔、筋、圆角),检查几何内存生命周期和性能预算,并录制课程作业演练。 + +### 阶段二:参数化草图 + +- [x] **M11 基础约束求解器**:实现固定、水平、垂直、重合和距离约束,建立残差方程、迭代求解与收敛报告。 +- [x] **M12 完整几何约束**:增加平行、垂直、相等、同心、相切、角度和半径约束,并检测欠约束、完全约束和过约束状态。 +- [x] **M13 新草图图元**:增加椭圆、长圆槽和样条曲线,支持绘制、编辑、离散化和轮廓检测。 +- [x] **M14 命名尺寸与参数表**:增加命名尺寸、表达式解析、单位校验和特征参数引用,修改参数后可全模型联动重建。 +- [x] **M15 草图阵列及阶段二验收**:实现草图线性/圆周阵列;完成“修改矩形尺寸驱动全部关联特征”的参数化闭环与自动化验收。 + +### 阶段三:装配体 + +- [x] **M16 多实体与零件引用基础**:扩展文档模型以安全表达多个实体、独立零件文档和稳定的面/边/轴引用。 +- [x] **M17 装配数据模型与装配树**:新增组件、变换和配合数据结构,支持零件插入、固定、隐藏、选择和装配树显示。 +- [x] **M18 配合约束求解**:实现重合、同心、距离、平行、垂直和角度配合,并保留合理自由度。 +- [x] **M19 干涉检查**:使用包围盒预筛与实体布尔交集检测干涉,显示干涉体积并高亮冲突区域。 +- [x] **M20 爆炸视图及阶段三验收**:实现组件爆炸方向、距离和动画;完成组件插入、配合、干涉与爆炸视图演练。 + +### 阶段四:工程图 + +- [x] **M21 图纸文档与图框**:建立工程图文档、视图和标注模型,提供 A3/A4 横竖图框、标题栏、比例与单位设置。 +- [x] **M22 第一角投影三视图**:从零件生成主视、俯视和左视轮廓,按第一角投影规则自动布局并支持比例调整。 +- [x] **M23 尺寸、公差与参数引用**:将模型命名尺寸映射到工程图,支持线性、角度、直径、半径尺寸及基础 ± 公差。 +- [x] **M24 剖视图**:实现裁剪平面、全剖视图、剖面轮廓和剖面线,保持与三维模型关联更新。 +- [x] **M25 工程图导出及阶段四验收**:支持 SVG/PNG 导出;完成任意零件一键生成 A4 三视图、尺寸和标题栏的课程交付演练。 + +### 最终收尾 + +- [ ] **M26 全项目交付验收**:按几何、约束、装配、工程图拆分测试套件;检查性能、WASM 内存、文件兼容、离线运行、错误恢复和文档完整性,完成代表性课程作业总演练。 + ## 1. 定位与目标 MiniSW 目前的定位是"能跑通基础草图和特征链路的原型",与 SolidWorks 的差距不在渲染层(Three.js 已足够),而在**建模能力覆盖**和**工程语义完整度**。 diff --git a/SUBPROXY_TASKS.md b/SUBPROXY_TASKS.md deleted file mode 100644 index e3447ae..0000000 --- a/SUBPROXY_TASKS.md +++ /dev/null @@ -1,152 +0,0 @@ -# MiniSW 并行子代理任务书 - -版本:v1.0 · 日期:2026-08-09 · 适用范围:MiniSW 项目全部子代理 - -## 1. 项目背景 - -MiniSW 是一款 AI 原生的 Web 参数化建模软件,参照 SolidWorks 的工作流(草图 → 特征 → 特征树 → 参数化重建)从零实现。核心差异点是"AI Agent 全自动建模":用户输入中文描述,系统解析为建模指令并自动完成建模。 - -技术栈:原生 JavaScript ESM,无打包器。Three.js r160(渲染)、manifold-3d 3.5.1(WebAssembly 布尔内核)、CSS2DRenderer(标注)、自定义中文规则解析器 + 可选 OpenAI 兼容 LLM 智能体。 - -运行方式:本地 HTTP 服务器打开 `index.html`(`python -m http.server 8899`,目录 `f:\Program Files\MiniSW`)。 - -## 2. 当前进度 - -已完成并通过 Node 冒烟测试(16/16 断言通过)的链路: - -- 几何内核:`math.js`(坐标系/平面映射)、`sketch.js`(图元/封闭环检测)、`geometry.js`(拉伸/旋转/圆柱/布尔/STL)、`doc.js`(文档模型/顺序重建) -- AI 解析器:`agent.js` 的中文规则解析路径(12 个 op 序列端到端建模成功) - -已编写但**未在真实浏览器验证**的模块:`main.js`(UI 主入口,约 1000+ 行)、`sketchui.js`(草图交互)、`viewport.js`(3D 视口)、`tree.js`(特征树)、`css/style.css`、`index.html`。 - -最高风险:`main.js` 从未在浏览器中实际运行,存在运行时错误、事件绑定失效、模块集成问题等未知风险。因此**浏览器联调(任务 0)是全部并行分支的前置依赖**。 - -## 3. 文件地图与归属 - -| 文件 | 归属 | 说明 | -|---|---|---| -| `js/math.js` | 任务 A | 坐标系、平面映射、几何工具(已稳定,可加固) | -| `js/sketch.js` | 任务 A | 图元、封闭环检测、轮廓校验(已稳定,可加固) | -| `js/geometry.js` | 任务 A | 特征构造、布尔运算、STL(已稳定,可加固) | -| `js/doc.js` | 任务 A | 文档模型、重建、序列化(已稳定,可加固) | -| `js/main.js` | 任务 B | UI 主入口,Ribbon/面板/状态机/AI 面板(最大风险文件) | -| `js/sketchui.js` | 任务 B | 草图交互工具(画线/矩形/圆/圆弧/标注/约束) | -| `js/viewport.js` | 任务 B | 3D 视口、导航、拾取、网格、高亮 | -| `js/tree.js` | 任务 B | 特征树 UI | -| `css/style.css` | 任务 B | SolidWorks 风格样式 | -| `index.html` | 任务 C | 页面骨架、AI 面板容器、依赖加载(import map) | -| `js/agent.js` | 任务 C | AI 解析器、DSL、LLM 接入 | -| `js/geometry.js` | 任务 0 | 联调发现的核心缺陷可修(需与 A 协调) | -| `js/main.js` | 任务 0 | 联调主战场(需与 B 协调) | -| 测试目录 `c:\Users\32919\.trae-cn\work\6a78857c0515f1e48ec3076f\` | 任务 0/A | Node 冒烟测试 `test.mjs`、`loader.mjs`、`swjs/`(工作区源码副本) | - -## 4. 通用协作规则 - -1. **文件互斥**:每个任务只能修改归属自己的文件。跨文件修改必须明确写入交接说明,交给主代理协调,禁止自行修改他人文件。 -2. **接口契约**:模块间通过命名导出通信(如 `geometry.js` 的 `buildExtrude/booleanOp/meshFromManifold`,`doc.js` 的 `Doc` 类)。改动签名必须同步更新所有调用方,并在报告中标明。 -3. **测试优先**:任何修改先跑测试(Node 冒烟测试或浏览器自动化),确认回归后再提交。 -4. **报告格式**:任务结束时报告必须包含:改动文件清单、新增/修复的功能点、测试结果(命令 + 断言数)、遗留问题、对下游任务的交接说明。 -5. **禁止事项**:不得重命名或移动他人文件;不得改变 `index.html` 的 import map 结构(任务 C 专属);不得删除 `swjs/` 副本的同步机制(改完工作区文件后同步 `Copy-Item` 到 `swjs/`)。 - -## 5. 任务 0:浏览器联调(前置,串行) - -**目标**:让 `main.js` 在真实浏览器中跑通,消除最高风险。 - -**职责**: - -1. 启动本地服务器,用浏览器自动化(或人工引导)加载 `index.html`,收集控制台错误。 -2. 逐项修复运行时错误:模块导入失败、事件绑定失效、初始化顺序错误、DOM 引用缺失。 -3. 验证最小闭环:新建草图 → 画矩形 → 拉伸 → 3D 显示 → 保存 → 加载 → STL 导出。 -4. 验证 AI 面板最小闭环:输入中文描述 → 生成建模指令 → 重建 → 视口刷新。 -5. 修复过程中发现的几何内核缺陷(`geometry.js`/`doc.js`)可以直接修,但需在报告中单独列出,供任务 A 复核。 - -**文件边界**:`js/main.js` 为主;`js/geometry.js`、`js/doc.js` 仅限修 bug;`index.html` 仅限修加载问题(不改 AI 面板结构);不得改动 `js/sketchui.js`、`js/viewport.js`、`js/tree.js` 的 API 签名。 - -**验收标准**: - -- 页面加载无控制台报错。 -- 上述两条最小闭环全部可手动操作完成。 -- 修复清单与交接说明写入报告。 - -## 6. 任务 A:特征引擎加固 - -**目标**:扩充特征引擎的健壮性与能力边界,把 Node 冒烟测试扩展到 25+ 断言。 - -**职责**: - -1. 扩充 `test.mjs`:拉伸"贯穿所有/对称"、旋转轴显式指定、多环轮廓(含嵌套孔)、圆柱原语、撤销重做栈(`Doc` 层)、STL 导出的二进制头与三角数校验。 -2. 修复测试暴露的缺陷:环方向、退化轮廓、极端尺寸、浮点边界。 -3. 加固 `buildRevolve` 的旋转轴校验与截面跨越检测。 -4. 为 `doc.js` 增加重建缓存的正确释放逻辑验证(manifold 对象的 delete 生命周期)。 -5. 保持 `geometry.js` 的导出签名不变。 - -**文件边界**:`js/math.js`、`js/sketch.js`、`js/geometry.js`、`js/doc.js`、测试目录(`test.mjs` 等)。不得改动 `js/main.js` 及任何 UI 文件。 - -**验收标准**: - -- Node 冒烟测试 25+ 断言全绿(`node --experimental-loader ./loader.mjs test.mjs`)。 -- 现有 16 条断言无回归。 -- 报告列出每个新增用例覆盖的场景与修复的缺陷。 - -## 7. 任务 B:UI 交互完善 - -**目标**:把 UI 从"能加载"提升到"可完整操作",对标 SolidWorks 的交互心智。 - -**职责**: - -1. 草图工具全流程:画线/矩形/圆/圆弧、标注编辑(双击)、裁剪、捕捉、约束的交互逻辑走查与修复。 -2. 特征工作流:属性面板(拉伸/切除/旋转表单)、特征树右键菜单(编辑草图/删除/编辑特征)、视图切换(前视/上视/右视/等轴测)。 -3. 3D 交互:拾取高亮、特征增量网格显示、相机操作(OrbitControls)的默认行为修正。 -4. 状态栏、Ribbon 分区、撤销/重做按钮的可用性打磨。 -5. 在浏览器自动化中走通 3 条完整用户路径并截图留证。 - -**文件边界**:`js/main.js`、`js/sketchui.js`、`js/viewport.js`、`js/tree.js`、`css/style.css`。不得改动 `js/geometry.js`、`js/doc.js` 的导出签名(如需新接口,向主代理申请,由任务 A 实现)。`index.html` 的 AI 面板区域不归本任务。 - -**验收标准**: - -- 3 条完整用户路径(草图→拉伸→保存;草图→切除→撤销→重做;视图切换+拾取高亮)在浏览器自动化中全部通过。 -- 无新增控制台错误。 -- 报告包含操作路径清单与截图。 - -## 8. 任务 C:AI 智能体扩展 - -**目标**:把 AI 从"基础指令解析"扩展到"覆盖常见建模意图的智能体"。 - -**职责**: - -1. 扩展 `agent.js` 的 DSL 与中文规则解析:倒角/圆角、阵列(线性/圆周)、镜像、抽壳、多草图组合等指令。 -2. 扩展 `executeOps` 到新指令的执行支持(与 `geometry.js` 对接的接口若缺,向主代理申请由任务 A 实现)。 -3. LLM 路径完善:OpenAI 兼容 API 接入、JSON 输出校验、失败重试与错误提示、多轮对话(基于当前模型状态的上下文)。 -4. AI 面板体验:指令历史、一键执行、执行结果反馈(成功/失败原因)。 -5. 离线可用性保障:无 LLM 时规则解析器仍能处理常见句式。 - -**文件边界**:`js/agent.js`、`index.html`(仅 AI 面板区域)、`js/main.js` 中 AI 面板相关的事件处理函数(如需)。不得改动 `js/sketch.js`、`js/geometry.js`、`js/doc.js`、`js/sketchui.js`、`js/viewport.js`、`js/tree.js`。 - -**验收标准**: - -- 10 条覆盖新指令的中文描述端到端建模成功(Node 或浏览器验证)。 -- 无效指令给出明确的中文错误信息,不崩溃。 -- 报告列出新增 DSL 指令、句式规则与测试用例。 - -## 9. 依赖关系与执行顺序 - -``` -任务 0(浏览器联调,串行前置) - │ - ├──→ 任务 A(特征引擎加固,可并行启动) - ├──→ 任务 B(UI 交互完善,依赖 0 的联调结果) - └──→ 任务 C(AI 智能体扩展,依赖 0 的最小闭环) -``` - -启动顺序建议:先完成任务 0,再并行启动 A、B、C。若任务 0 无法完整完成(环境限制),则 A 可立即并行(不依赖浏览器),B、C 的验收改为"代码走查 + 局部验证"。 - -任务间的接口申请统一走主代理:B 或 C 需要新几何接口时,提交接口规格(名称、参数、返回值、语义),主代理转交 A 实现后,B/C 再集成。 - -## 10. 验收总表 - -| 任务 | 核心验收 | 完成标志 | -|---|---|---| -| 0 浏览器联调 | 页面无报错,两条最小闭环可操作 | 修复清单 + 交接说明 | -| A 特征引擎 | Node 测试 25+ 断言全绿,16 条无回归 | 测试报告 + 缺陷清单 | -| B UI 交互 | 3 条完整用户路径浏览器自动化通过 | 操作路径 + 截图 | -| C AI 智能体 | 10 条新指令端到端建模成功 | DSL 指令表 + 测试用例 | diff --git a/TASK_0_REPORT.md b/TASK_0_REPORT.md deleted file mode 100644 index 8eccc4b..0000000 --- a/TASK_0_REPORT.md +++ /dev/null @@ -1,88 +0,0 @@ -# MiniSW 任务 0 报告:浏览器联调 - -版本:v1.0 · 日期:2026-08-09 · 执行者:主代理(任务 0) - -## 1. 目标 - -把任务 A/B/C 的成果在真实浏览器环境中跑通,验证两条完整闭环并修复联调中发现的问题: - -- 最小建模闭环:新建草图 → 画矩形 → 拉伸 → 3D 显示 → 保存 → 加载 → STL 导出 -- AI 面板闭环:自然语言 → 解析 → 特征生成 → 建模 → 导出 - -## 2. 测试环境 - -| 项 | 值 | -|---|---| -| 服务器 | `python -m http.server 8899`(目录 `f:\Program Files\MiniSW`) | -| 浏览器 | agent-browser(Chromium headless,CDP 驱动) | -| 页面 | http://localhost:8899 | -| 依赖加载 | three.js、OrbitControls、manifold.wasm 均从 CDN 正常加载(仅 favicon 404,无害) | - -## 3. 闭环验证结果 - -### 3.1 最小建模闭环 ✅ - -| 步骤 | 操作 | 结果 | -|---|---|---| -| 1 | 进入"前视草图",选矩形工具 | 草图模式激活,HUD 显示"草图1 · 前视" | -| 2 | 视口绘制矩形(pointerdown × 2) | HUD 更新"1 实体" | -| 3 | 拉伸凸台,深度 20,确定 | 特征树新增"拉伸",状态栏"1 草图 · 1 特征 · 尺寸 225.989×135.594×20 mm" | -| 4 | 3D 显示验证 | WebGL 持续绘制(1.5s 内 3318 次 drawElements),视口区域实测含实体着色像素 | -| 5 | 保存 | 捕获 JSON 完整:1 草图、1 特征(extrude depth=20)、矩形 226×135.6 | -| 6 | 加载 | 文件上传后重建正确,尺寸精确还原(100×60×25 mm) | -| 7 | STL 导出 | 12 三角形(长方体 6 面×2),文件长度 = 84+50×12 精确匹配 | - -### 3.2 AI 面板闭环 ✅ - -| 示例输入 | 结果 | -|---|---| -| 120×80×15 底板 + 四角直径8孔 + 中心半径12孔 | 3 草图 3 特征,尺寸 120×80×15 正确 | -| 空心圆柱,外径60,内径40,高50 | 圆柱 + 内孔,外径 60 → 半径 30 正确 | -| 200×120×10 板 + 60×40 矩形槽 + 四角直径6孔 | 3 草图 3 特征(主体/矩形槽/四角孔),尺寸正确 | -| 自定义输入"60×40×12 板 + 中心直径10圆孔" | 特征树正确新增板 + 孔 | -| 复合模型 STL 导出 | 1072 三角形,文件长度精确匹配 | - -### 3.3 视口交互 ✅ - -鼠标拖拽旋转验证:视口区域 30.3% 像素变化,OrbitControls 正常工作。 - -## 4. 联调发现并修复的问题 - -| # | 文件 | 问题 | 修复 | -|---|---|---|---| -| 1 | `js/main.js` | 隐藏的 file input 未挂载 DOM,自动化/外部代码无法触发"打开" | 挂载到 body 并保持 `display:none` | -| 2 | `js/agent.js` | 槽尺寸正则 `[槽坑凹][^\d]*(\d+)...` 只在"槽"字后找数字,"60×40 的矩形槽"解析失败,槽未生成 | 支持数字在槽字前或后(双分支正则) | -| 3 | `js/agent.js` | "中间开一个矩形槽"被中心孔正则 `/中心\|中间\|.../` 误触发,生成假中心孔 | 位置词后 12 字符内必须出现"孔"才触发 | -| 4 | `js/agent.js` | 四角孔正则 `四角各?一个?[^\d]*?(?:直径\|半径)?(\d+)` 匹配到数量"4 个"而非直径"6" | 优先匹配"直径N / 半径N"字样,数量词兜底 | -| 5 | `js/agent.js` | "外径60 / 内径40"未识别,空心圆柱半径回退默认 20(实际应为 30) | 新增 `outerDia` / `innerDia` 解析,圆柱分支优先使用 | -| 6 | `js/geometry.js` | STL 80 字节 header 全为 0 | 写入 "MiniSW binary STL" + ISO 时间戳标识 | - -## 5. 测试结果 - -Node 回归测试从 38 条扩充到 **43 条全部通过**,新增 5 条针对联调修复的断言: - -| 新增断言 | 覆盖 | -|---|---| -| 矩形槽尺寸 60×40 解析正确 | 槽正则修复 | -| 中间开槽不误判为中心孔 | 中心孔误触发修复 | -| 四角孔直径6 → 半径3 × 4 | 四角孔正则修复 | -| 外径60 → 半径30 | 外径解析 | -| 内径40 → 内孔半径20 | 内径解析 | - -## 6. 遗留问题 - -| 问题 | 严重性 | 说明 | -|---|---|---| -| 多次 AI 生成叠加建模,无"新文档"入口 | 低 | 连续 AI 指令在同一文档累积特征,尺寸显示为整体包围盒(联调中曾误判为解析错误,实际是叠加结果) | -| LLM 路径未联调 | 中 | LLM 配置(`runLLM`)依赖外部 API Key,本次联调仅验证内置解析器路径 | -| 视口拾取精度 | 低 | Raycaster 拾取在 headless 下未做像素级验证,依赖任务 B 的 DOM 断言结果 | - -## 7. 验收对照 - -| 验收标准 | 状态 | -|---|---| -| 最小闭环:草图 → 拉伸 → 3D → 保存 → 加载 → STL | ✅ 全链路通过 | -| AI 面板闭环:自然语言 → 建模 | ✅ 4 组输入通过 | -| 控制台无错误 | ✅ 仅 favicon 404(无害) | -| 修复问题回归 | ✅ 43 条断言全部通过 | -| 报告 | ✅ 本文档 + TASK_A_REPORT.md | diff --git a/TASK_A_REPORT.md b/TASK_A_REPORT.md deleted file mode 100644 index 7f1f690..0000000 --- a/TASK_A_REPORT.md +++ /dev/null @@ -1,92 +0,0 @@ -# MiniSW 任务 A 报告:特征引擎加固 - -版本:v1.0 · 日期:2026-08-09 · 执行者:子代理 A - -## 1. 目标 - -在任务 B 的 UI 基础上加固底层特征引擎,补齐旋转、切除、布尔运算的边界情况,建立文档层撤销/恢复能力,并把 Node 冒烟测试从 16 条断言扩充到 38 条,为核心几何链路提供回归保障。 - -## 2. 改动文件清单 - -| 文件 | 改动类型 | 说明 | -|---|---|---| -| `js/doc.js` | 增强 | 新增 `snapshot()` / `restore()` 文档层快照;`deleteSketch()` 增加"被特征引用则拒绝删除"保护 | -| `js/geometry.js` | 加固 | `buildRevolve` 新增旋转轴退化/跨轴/轴不存在三类校验;`buildExtrude` 新增非流形状态检查 | -| `tests/test.mjs` | 大幅扩充 | 断言从 16 条扩充到 38 条,覆盖旋转、切除、嵌套轮廓、布尔叠加、快照、错误路径、delete 生命周期 | - -## 3. 特征引擎加固点 - -### 3.1 旋转特征(buildRevolve) - -| 加固项 | 说明 | 失败表现 | -|---|---|---| -| 旋转轴退化 | 轴线实体长度 `< 1e-6` 时报错,避免 `atan2` 除零 | `旋转轴退化` | -| 截面跨轴校验 | 截面点集同时分布在 u>0 与 u<0 两侧时报错(manifold revolve 对跨轴截面行为未定义) | `旋转截面跨越了旋转轴,请把截面放在轴的一侧` | -| 显式轴存在性 | `axisEntityId` 指定的实体不存在时报错,而不是静默回退 | `指定的旋转轴实体不存在` | -| 轴类型约束 | 仅 `line` / `centerline` 可作为轴,其他类型忽略并回退默认轴 | — | - -### 3.2 拉伸特征(buildExtrude) - -在 `CrossSection.extrude()` 后检查 `tool.status()`,非 `NoError` 时释放资源并抛错,避免把非流形实体带入后续布尔运算。 - -### 3.3 文档层快照(Doc.snapshot / restore) - -- `snapshot()` 复用 `toJSON()`,几何缓存(`bodyManifold` / `bodyMesh` / `deltaMeshes` / `bbox`)不参与序列化 -- `restore(snap)` 从 JSON 重建文档结构并清空几何缓存,调用方随后执行 `rebuild()` 刷新几何 -- 该机制被任务 B 的撤销/重做栈复用,也供未来编辑特征参数后回滚使用 - -### 3.4 草图删除保护 - -`deleteSketch(id)` 增加引用检查:若有特征仍引用该草图,返回 `false` 拒绝删除,防止重建时出现"草图不存在"级联错误。 - -## 4. 测试结果 - -### 4.1 测试命令 - -```bash -cd c:\Users\32919\.trae-cn\work\6a78857c0515f1e48ec3076f -node --experimental-loader ./loader.mjs test.mjs -``` - -### 4.2 结果 - -**通过: 38,失败: 0 — 全部通过** - -### 4.3 测试覆盖清单 - -| # | 场景 | 断言要点 | -|---|---|---| -| 1 | 几何内核初始化 | manifold wasm 加载成功 | -| 2 | 手动建模:底板 + 四角孔 | 重建无错误、实体网格、包围盒 120×80×15、特征增量网格存在、STL 导出 | -| 3 | 旋转特征(中心线作轴) | 旋转重建无错误、生成实体、外径 80 | -| 4 | AI 解析器 → op 序列 → 执行 | 解析识别、校验通过、重建无错误、特征数 ≥3 | -| 5 | 空心圆柱 | 圆柱 + 内孔两个特征、重建无错误 | -| 6 | 对称拉伸 | z∈[-5,5] 对称 | -| 7 | 贯穿所有切除 | 体积 60000-π·25·10 误差 <1% | -| 8 | 显式旋转轴 | 外径 50 正确 | -| 9 | 嵌套矩形孔(多环轮廓) | 体积 72000(100×80×10 - 40×20×10) | -| 10 | 圆柱原语 | 包围盒 40×40×50 | -| 11 | 圆柱凸台叠加(顺序布尔) | 凸台顶部 z=15、合并体积 = 60000+π·225·5 | -| 12 | 文档层快照/撤销恢复 | 快照1 → 1 特征;快照2 → 2 特征,均重建无错误 | -| 13 | 旋转截面跨越旋转轴 | 报错信息包含"旋转轴" | -| 14 | 退化轮廓 | 单线段拉伸被拦截报错 | -| 15 | STL 二进制头部 | 三角数一致、文件长度 = 84+50×N | -| 16 | 重复重建稳定性(delete 生命周期) | 连续 3 次重建体积一致 | -| 17 | 显式旋转轴不存在 | 报错信息包含"轴" | - -## 5. 关键修复记录 - -| 问题 | 原因 | 修复 | -|---|---|---| -| revolve 跨轴截面产生垃圾几何 | manifold `revolve` 对跨轴截面行为未定义 | 重建前遍历截面点,u 轴两侧均有分布时抛错 | -| `axisEntityId` 指向不存在的实体时静默回退 | 未校验轴实体存在性 | 增加显式存在性检查并抛错 | -| 旋转轴退化为点导致 `atan2` 异常 | 轴实体两端点重合 | 长度 `< 1e-6` 时报"旋转轴退化" | -| `deleteSketch` 删除被引用的草图导致重建崩溃 | 无引用检查 | 返回 `false` 拒绝删除 | -| 拉伸非流形实体进入布尔运算 | 未检查 `tool.status()` | 非 `NoError` 时抛错并释放 | - -## 6. 对下游任务的交接说明 - -- **doc.js / geometry.js 接口未变**:新增方法均为增量(`snapshot` / `restore`),原有导出签名不变 -- **`deltaMeshes` 契约**:`rebuild()` 始终填充每个特征的增量网格,供任务 B 的高亮/拾取消费 -- **错误特征标记**:单个特征失败时仅标记 `f.error`,不中断整体重建,后续特征继续执行 -- **任务 0 依赖**:快照/恢复、delete 生命周期、错误路径等已在 Node 层验证,需在浏览器联调中复核实际 UI 触发路径(撤销/重做按钮、特征删除右键菜单) diff --git a/TASK_B_REPORT.md b/TASK_B_REPORT.md deleted file mode 100644 index 865dc4d..0000000 --- a/TASK_B_REPORT.md +++ /dev/null @@ -1,125 +0,0 @@ -# MiniSW 任务 B 报告:UI 交互完善 - -版本:v1.0 · 日期:2026-08-09 · 执行者:子代理 B - -## 1. 目标 - -把 UI 从"能加载"提升到"可完整操作",对标 SolidWorks 的交互心智。完善草图工具、特征工作流、3D 交互、状态栏/Ribbon 打磨,并在浏览器自动化中走通 3 条完整用户路径。 - -## 2. 改动文件清单 - -| 文件 | 改动类型 | 说明 | -|---|---|---| -| `js/main.js` | 修复+增强 | 运行时缺陷修复、撤销/重做按钮状态管理、特征工作流完善、草图预检查、AI 操作入栈 | -| `js/sketchui.js` | 修复 | 实体选择交互修复、约束应用逻辑修正 | -| `js/viewport.js` | 修复+增强 | toNDC 坐标转换修复(getBoundingClientRect 后备)、几何/材质内存释放、高亮渲染 | -| `js/tree.js` | 增强 | 特征右键菜单新增"编辑草图"、单击草图节点进入编辑 | -| `css/style.css` | 修复+增强 | CSS 选择器修正(.rib-tabs → .ribbon-tabs)、HUD 样式、工具按钮激活态 | -| `index.html` | 无改动 | — | - -## 3. 新增/修复的功能点 - -### 3.1 运行时缺陷修复 -- **main.js `rebuildAndRefresh()` 空指针**:`ui.sk` 可能为 null,增加 `ui.sk && doc.sketchById(ui.sk.id)` 双重检查 -- **main.js `updateStatus()` 未调用**:在 `rebuildAndRefresh()` 中补充调用 -- **CSS 选择器错误**:`.rib-tabs` 修正为 `.ribbon-tabs` -- **工具按钮激活态**:为 `tool:` 类型按钮补充 `data-tool` 属性,确保 `updateToolButtons()` 正确高亮当前工具 -- **特征按钮草图模式禁用**:草图模式下拉伸/切除/旋转按钮标记 `bad` 类 - -### 3.2 草图工具交互 -- **实体列表选择**:属性面板中实体列表项点击可选中对应实体,同步 `ui.sel.ents` 并刷新视口 -- **矩形/圆/圆弧绘制**:验证 PointerEvent 事件链(pointerdown → pointermove → pointerdown)正确触发绘制 -- **工具提示**:每个草图工具切换时显示操作提示(如"矩形:单击一角→拖到对角") - -### 3.3 特征工作流 -- **拉伸/切除/旋转表单**:属性面板显示参数表单(深度、双向、反向等),确定/取消按钮 -- **特征树右键菜单**:特征节点右键弹出"编辑草图/编辑特征参数/删除特征" -- **草图节点单击编辑**:特征树中单击草图节点直接进入草图编辑模式 -- **特征预检查**:创建特征前检查是否在草图模式、草图是否有实体 - -### 3.4 撤销/重做 -- **按钮状态管理**:`updateUndoRedo()` 根据栈深度启用/禁用按钮 -- **AI 操作入栈**:`sendAI()` 在执行 AI 指令前 `pushUndo()`,失败时从栈中弹出并恢复 -- **特征创建入栈**:新建草图、创建特征、裁剪、约束等操作前均 `pushUndo()` - -### 3.5 3D 视口 -- **toNDC 坐标转换修复**:`getBoundingClientRect()` 在部分浏览器环境下返回 0 宽高,增加 `clientWidth/clientHeight` + offset 链遍历后备 -- **内存释放**:`updateBody()` 和 `highlightFeature()` 中正确 dispose 旧 geometry/material,防止内存泄漏 -- **拾取高亮**:`pickFeature()` 使用 Raycaster 对 pickMeshes 求交,命中后 `highlightFeature()` 渲染半透明橙色高亮 - -### 3.6 状态栏与 HUD -- **HUD 显示**:草图模式显示草图名/平面/实体数/约束数/标注数;模型模式显示"3D 模型模式" -- **状态栏**:显示草图数/特征数/错误数/包围盒尺寸 -- **重建反馈**:重建后显示"重建完成"或错误信息 - -## 4. 测试结果 - -### 4.1 测试环境 -- 浏览器:集成浏览器自动化(Chromium 内核) -- 服务器:`python -m http.server 8899`(目录 `f:\Program Files\MiniSW`) -- 页面加载:无控制台错误 - -### 4.2 三条用户路径验证 - -#### 路径 1:草图 → 拉伸 → 保存 ✅ -| 步骤 | 操作 | 结果 | -|---|---|---| -| 1 | 点击"前视草图" | 草图1 创建,进入草图模式,HUD 显示"草图1 · 前视 0 实体" | -| 2 | 选择"矩形"工具,绘制矩形 | HUD 更新为"1 实体",属性面板显示矩形 #2 | -| 3 | 点击"拉伸凸台",深度 10mm,确定 | 特征树新增"拉伸",状态栏"1 草图 · 1 特征 · 尺寸 129.775×162.219×10 mm" | -| 4 | 点击"保存" | 无错误(JSON 序列化 + Blob 下载触发) | - -#### 路径 2:草图 → 切除 → 撤销 → 重做 ✅ -| 步骤 | 操作 | 结果 | -|---|---|---| -| 1 | 新建前视草图2,画圆 | HUD 显示"草图2 · 前视 1 实体" | -| 2 | 点击"拉伸切除",确定 | 特征树新增"切除",状态"2 草图 · 2 特征" | -| 3 | 点击"撤销" | 切除消失,状态"2 草图 · 1 特征",重做按钮启用 | -| 4 | 点击"重做" | 切除恢复,状态"2 草图 · 2 特征",重做按钮禁用 | - -#### 路径 3:视图切换 + 拾取高亮 ✅ -| 步骤 | 操作 | 结果 | -|---|---|---| -| 1 | 切换到"视图"选项卡 | 显示前视/上视/右视/等轴测按钮 | -| 2 | 依次点击 4 个视图按钮 | 全部无错误,相机视角切换 | -| 3 | 特征树点击"拉伸"节点 | 节点选中(sel 类),属性面板显示特征参数表单 | -| 4 | 3D 视口拾取 | 见遗留问题 | - -### 4.3 控制台状态 -全部测试过程中**无控制台错误**。 - -## 5. 遗留问题 - -### 5.1 3D 视口拾取(环境限制) -在集成浏览器自动化环境中,`getBoundingClientRect()` 偶发返回 0 宽高,已通过 offset 链后备修复坐标转换。但 WebGL 渲染缓冲在不可见标签页中可能不完整渲染,导致 Raycaster 求交结果不稳定。特征树拾取高亮路径已验证可用,3D 视口直接拾取需在可见浏览器窗口中进一步验证。 - -### 5.2 截图限制 -集成浏览器自动化在当前环境中无法截图(标签页不可见),3 条路径的验证结果以 DOM 状态断言替代截图。 - -### 5.3 草图工具交互细节 -- 矩形工具为"两次点击"模式(click-move-click),非拖拽模式,与 SolidWorks 的拖拽矩形有差异 -- 圆弧工具需要三次点击确定,无预览圆心标记 - -## 6. 对下游任务的交接说明 - -### 6.1 对任务 A(特征引擎) -- `doc.deltaMeshes` 在 `rebuildAndRefresh()` 中被 `vp.setDeltas()` 和 `vp.buildPickTargets()` 消费,请确保 `doc.rebuild()` 正确填充 `deltaMeshes`(每个特征的增量网格) -- `doc.toJSON()` / `doc.fromJSON()` 已被保存/加载功能调用,请确保序列化格式稳定 - -### 6.2 对任务 C(AI 智能体) -- AI 操作已接入撤销栈:`sendAI()` 中 `pushUndo()` → `executeOps()` → 失败时 `restore(snap)` -- `executeOps()` 异常时会从 undoStack 弹出快照并恢复,确保 AI 指令失败不影响模型状态 -- AI 面板的示例按钮(chip)data-ex 属性包含中文描述,可直接被 `parseNaturalLanguage()` 消费 - -### 6.3 对任务 0(浏览器联调) -- 页面加载无控制台错误 -- `toNDC()` 的 `getBoundingClientRect()` 后备逻辑需在真实浏览器中验证(当前环境的间歇性 0 值问题可能不存在于真实浏览器) -- 所有 Ribbon 按钮、草图工具、特征表单、特征树交互均已验证无运行时错误 - -## 7. 验收对照 - -| 验收标准 | 状态 | 说明 | -|---|---|---| -| 3 条完整用户路径浏览器自动化通过 | ✅ | 路径 1/2 全通过,路径 3 视图切换+特征树拾取通过 | -| 无新增控制台错误 | ✅ | 全程 0 控制台错误 | -| 报告包含操作路径清单与截图 | ⚠️ | 操作路径清单完整;截图因环境限制以 DOM 断言替代 | diff --git a/TASK_C_REPORT.md b/TASK_C_REPORT.md deleted file mode 100644 index ee85ca5..0000000 --- a/TASK_C_REPORT.md +++ /dev/null @@ -1,155 +0,0 @@ -# 任务 C 完成报告:AI 智能体扩展 - -版本:v1.0 · 日期:2026-08-10 · 任务:C(AI 智能体扩展) - -## 1. 改动文件清单 - -| 文件 | 改动类型 | 说明 | -|---|---|---| -| `js/agent.js` | 重大扩展 | 新增 7 种高级 DSL 指令、宏展开引擎、解析器增强、LLM 多轮上下文 | -| `index.html` | 修改 | AI 面板新增 10 个示例按钮、清空对话按钮 | -| `js/main.js` | 修改 | AI 面板事件处理增强:历史记录、执行反馈、LLM 上下文传递 | - -## 2. 新增 DSL 指令 - -### 2.1 高级 op(宏展开层) - -| 指令 | 参数 | 展开为 | 说明 | -|---|---|---|---| -| `chamfer_rect` | cx, cy, w, h, chamfer | 8 段直线轮廓 | 倒角矩形(四角 45° 斜角) | -| `fillet_rect` | cx, cy, w, h, fillet | 直线+圆弧点列轮廓 | 圆角矩形(四角圆弧) | -| `polygon` | points[[u,v],...] | 顺序连线 | 自定义多边形 | -| `shell` | w, h, d, wall, plane | 外凸台+内腔切除 | 抽壳盒体 | -| `mirror` | axis(x\|y) | 复制上一特征并镜像 | 镜像复制 | -| `pattern_linear` | count, dx, dy | 复制上一特征并平移 | 线性阵列 | -| `pattern_circular` | count, cx, cy | 复制上一特征并旋转 | 圆周阵列 | - -### 2.2 宏展开引擎(expandOps) - -- 位于 `validateOps` / `executeOps` 之前,将高级 op 展开为已有原语序列 -- **无需修改 geometry.js / doc.js 的接口契约** -- 幂等设计:已经是原语的 op 原样通过 -- 特征组查找(lastFeatureGroup):自动识别上一个特征及其所属草图和实体 - -### 2.3 解析器增强(parseNaturalLanguage) - -新增关键词识别: -- 倒角:`/倒角/` + 支持 "Nmm倒角" 和 "倒角N" 两种句式 -- 圆角:`/圆角|过渡圆/` + 支持 "半径N" 作为回退 -- 抽壳:`/抽壳|空心盒|中空盒/` -- 线性阵列:`/线性阵列|直线阵列|排成一行|排成一列/` -- 圆周阵列:`/圆周阵列|环形阵列|圆周均布|环形均布/` -- 镜像:`/镜像|对称复制/` -- 凸台:`/凸台|凸起|加.*圆柱|顶.*圆柱|加.*凸/` - -特征冲突防护: -- 四角孔仅在 `!hasFillet && !hasChamfer` 时生成(避免与圆角/倒角冲突) -- 中心孔仅在 `!hasBoss` 时生成(避免凸台直径被误用为孔径) -- 纯抽壳路径仅在无附加特征时触发(`!hasHole && !hasChamfer && !hasFillet && !hasMirror && !hasBoss`) - -### 2.4 LLM 智能体完善 - -- OpenAI 兼容 API 接入(`callLLMAPI`) -- JSON 输出校验(`extractOps` + `validateOps`) -- 失败重试机制(maxRetries=1,带修正提示) -- 多轮对话上下文(`modelStateSummary` + `aiHistory`,最近 6 条) -- 离线保障:无 LLM 配置时自动回退到内置解析器 - -## 3. 测试结果 - -### 3.1 测试命令 - -```bash -cd c:\Users\32919\.trae-cn\work\6a788fba0515f1e48ec30a23 -node test_agent.mjs -``` - -### 3.2 测试结果 - -**通过: 103,失败: 0 — 全部通过** - -### 3.3 测试覆盖 - -#### 10 条新指令端到端测试 - -| # | 测试名称 | 输入描述 | 原始指令 | 展开后 op | 特征数 | -|---|---|---|---|---|---| -| 1 | 倒角矩形拉伸 | 100×80×20 矩形板,四边倒角5mm | 3 | 10 | 1 | -| 2 | 圆角矩形拉伸 | 100×80×20 矩形,四角圆角半径8 | 3 | 27 | 1 | -| 3 | 抽壳盒体 | 100×80×40 抽壳盒子,壁厚3mm | 1 | 6 | 2 | -| 4 | 线性阵列孔 | 200×100×10 板,直径10通孔,线性阵列4个间距40 | 7 | 15 | 5 | -| 5 | 圆周阵列孔 | 直径120厚10圆盘,直径10孔距中心40,圆周阵列6个 | 5 | 19 | 7 | -| 6 | 圆柱凸台 | 100×80×10 底板,直径30高20圆柱凸台 | 4 | 4 | 2 | -| 7 | 倒角+中心孔 | 80×80×20 带5mm倒角方块,中心直径20通孔 | 6 | 13 | 2 | -| 8 | 圆角+抽壳 | 100×80×30 圆角盒子,半径10,抽壳壁厚2 | 6 | 30 | 2 | -| 9 | 线性阵列+镜像 | 150×100×10 板,直径8孔,线性阵列3个间距50,镜像 | 8 | 15 | 5 | -| 10 | 凸台+镜像 | 100×60×10 板,凸台直径20高15在x-30,镜像到右侧 | 5 | 5 | 3 | - -#### 回归测试(3 条原有指令) - -| 输入 | 特征数 | 结果 | -|---|---|---| -| 120×80×15 底板,四角直径8孔,中心半径12孔 | 3 | 通过 | -| 空心圆柱,外径60,内径40,高50 | 2 | 通过 | -| 200×120×10 板,60×40 矩形槽,四角4个直径6孔 | 3 | 通过 | - -#### 无效指令容错测试(4 条) - -| 输入 | 结果 | -|---|---| -| "asdfghjkl" | 正确返回 null | -| "" | 正确返回 null | -| "你好" | 正确返回 null | -| "做一个不知道什么东西的模型" | 正确返回 null | - -#### 高级 op 直接构造测试 - -| 测试 | 结果 | -|---|---| -| polygon 五边形展开为 5 段直线 | 通过 | -| mirror 无前置特征 → 校验拦截 | 通过 | -| pattern_circular 无前置特征 → 校验拦截 | 通过 | - -## 4. 关键修复记录 - -| 问题 | 原因 | 修复 | -|---|---|---| -| mirrorEntityOp 崩溃 | rect/circle op 使用 `cx`/`cy` 属性,但镜像函数错误访问 `op.c[neg]`(c 数组是 executeOps 后的内部格式) | 改为 `if (axis === 'x') op.cx = -op.cx; else op.cy = -op.cy;` | -| validateOps 对无前置特征的 mirror/pattern 抛异常 | `expandOps` 内部 throw 未被捕获 | 用 try-catch 包裹,返回错误字符串 | -| 圆角盒子 "半径10" 未被提取 | "半径10" 与 "圆角" 分离,正则无法匹配 | 添加 `?? radius` 作为回退 | -| 四角圆角误触发四角孔 | 四角孔条件未排除圆角/倒角场景 | 添加 `!hasFillet && !hasChamfer` 条件 | -| 抽壳盒子走了板路径 | 纯抽壳条件未排除附加特征 | 添加 `!hasMirror && !hasBoss` 等条件 | -| 凸台直径被误用于中心孔 | 中心孔条件未排除凸台场景 | 添加 `!hasBoss` 条件 | -| 倒角 "5mm倒角" 句式未匹配 | 正则只匹配 "倒角N",不匹配 "Nmm倒角" | 添加反向匹配 `(\\d+)\\s*(?:mm)?\\s*倒角` | - -## 5. index.html AI 面板更新 - -新增 10 个示例按钮(chip),覆盖所有新指令: -- 底板+孔、空心圆柱、槽+孔(原有) -- 倒角、圆角、抽壳、线性阵列、圆周阵列、凸台、镜像(新增) - -新增清空对话按钮(↻),重置 AI 历史记录。 - -## 6. main.js AI 面板更新 - -- `aiHistory` 数组:维护最近 12 条对话(6 轮),用于 LLM 多轮上下文 -- `modelStateSummary()`:生成当前模型状态摘要,传递给 LLM -- `expandOps` 集成:执行前展开高级 op,反馈中显示原始/展开后 op 数量 -- 执行结果反馈:成功/失败原因、特征列表、错误特征数 - -## 7. 遗留问题 - -| 问题 | 严重性 | 说明 | -|---|---|---| -| 矩形槽尺寸提取正则有 `[^\\d]` bug | 低 | 在 regex literal 中 `\\d` 匹配字面 "\d" 而非数字,但中文场景下不影响功能 | -| "外径"/"内径" 未作为直径/半径别名 | 低 | "空心圆柱,外径60,内径40" 中外径/内径未提取,使用默认值;特征数正确但尺寸不准 | -| 镜像只复制最后一个特征 | 设计限制 | 用户期望镜像整个特征组时需手动指定;当前设计为镜像最后单个特征 | -| LLM 路径未经浏览器实测 | 中 | LLM 集成代码已完成但未在真实浏览器环境中验证(依赖任务 0 联调) | - -## 8. 对下游任务的交接说明 - -- **agent.js 导出接口未变**:`parseNaturalLanguage`、`expandOps`、`validateOps`、`executeOps`、`runLLM`、`getLLMSettings`、`setLLMSettings`、`DSL_SPEC` 均保持原有签名 -- **新增导出**:`expandOps` 为新增导出函数,main.js 已集成调用 -- **doc.js / geometry.js 未修改**:所有新指令通过宏展开为已有原语,不涉及底层接口变更 -- **index.html import map 未改动**:仅修改 AI 面板 DOM 结构 -- **任务 0 依赖**:main.js 的 AI 面板事件处理需在浏览器联调中验证 diff --git a/assets/solidworks/appearance-textures/ash_bump.jpg b/assets/solidworks/appearance-textures/ash_bump.jpg new file mode 100644 index 0000000..2be4d53 Binary files /dev/null and b/assets/solidworks/appearance-textures/ash_bump.jpg differ diff --git a/assets/solidworks/appearance-textures/birch_bump.jpg b/assets/solidworks/appearance-textures/birch_bump.jpg new file mode 100644 index 0000000..2f3b483 Binary files /dev/null and b/assets/solidworks/appearance-textures/birch_bump.jpg differ diff --git a/assets/solidworks/materials/en-US/SolidWorks DIN Materials.sldmat b/assets/solidworks/materials/en-US/SolidWorks DIN Materials.sldmat new file mode 100644 index 0000000..1728b31 Binary files /dev/null and b/assets/solidworks/materials/en-US/SolidWorks DIN Materials.sldmat differ diff --git a/assets/solidworks/materials/en-US/shader_mapping.dat b/assets/solidworks/materials/en-US/shader_mapping.dat new file mode 100644 index 0000000..31b6b99 --- /dev/null +++ b/assets/solidworks/materials/en-US/shader_mapping.dat @@ -0,0 +1,399 @@ +leather, Leather (PW) +sponge, Sponge (PW) +grass, Grass (PW) +sand, Sand (PW) +skin, Skin (PW) +heavyclouds, Heavy Clouds (PW) +lightclouds, Light Clouds (PW) +clearsky, Clear Sky (PW) +waterstill, Water Still (PW) +orientedstrandboard, Oriented Strand Board (OSB) (PW) +hardwoodfloor, Hardwood Floor (PW) +hardwoodfloor2, Hardwood Floor 2 (PW) +hardwoodfloor3, Hardwood Floor 3 (PW) +laminatefloor, Laminate Floor (PW) +laminatefloor2, Laminate Floor 2 (PW) +laminatefloor3, Laminate Floor 3 (PW) +polishedash, Polished Ash (PW) +satinfinishash, Satin Finish Ash (PW) +unfinishedash, Unfinished Ash (PW) +polishedbeech, Polished Beech (PW) +satinfinishbeech, Satin Finish Beech (PW) +unfinishedbeech, Unfinished Beech (PW) +polishedbirch, Polished Birch (PW) +satinfinishbirch, Satin Finish Birch (PW) +unfinishedbirch, Unfinished Birch (PW) +polishedcherry, Polished Cherry (PW) +satinfinishcherry, Satin Finish Cherry (PW) +unfinishedcherry, Unfinished Cherry (PW) +polishedmahogany, Polished Mahongany (PW) +satinfinishmahogany, Satin Finish Mahogany (PW) +unfinishedmahogany, Unfinished Mahogany (PW) +polishedmaple, Polished Maple (PW) +satinfinishmaple, Satin Finish Maple (PW) +unfinishedmaple, Unfinished Maple (PW) +polishedoak, Polished Oak (PW) +satinfinishoak, Satin Finish Oak (PW) +unfinishedoak, Unfinished Oak (PW) +polishedpine, Polished Pine (PW) +satinfinishpine, Satin Finish Pine (PW) +unfinishedpine, Unfinished Pine (PW) +polishedrosewood, Polished Rosewood (PW) +satinfinishrosewood, Satin Finish Rosewood (PW) +unfinishedrosewood, Unfinished Rosewood (PW) +polishedsatinwood, Polished Satinwood (PW) +satinfinishsatinwood, Satin Finish Satinwood (PW) +unfinishedsatinwood, Unfinished Satinwood (PW) +polishedspruce, Polished Spruce (PW) +satinfinishspruce, Satin Finish Spruce (PW) +unfinishedspruce, Unfinished Spruce (PW) +polishedteak, Polished Teak (PW) +satinfinishteak, Satin Finish Teak (PW) +unfinishedteak, Unfinished Teak (PW) +polishedwalnut, Polished Walnut (PW) +satinfinishwalnut, Satin Finish Walnut (PW) +unfinishedwalnut, Unfinished Walnut (PW) +redceramictile, Red Ceramic Tile (PW) +floortile7, Floor Tile 7 (PW) +floortile6, Floor Tile 6 (PW) +floortile5, Floor Tile 5 (PW) +floortile4, Floor Tile 4 (PW) +floortile3, Floor Tile 3 (PW) +floortile2, Floor Tile 2 (PW) +floortile1, Floor Tile 1 (PW) +cobblestone4, Cobblestone 4 (PW) +cobblestone3, Cobblestone 3 (PW) +cobblestone2, Cobblestone 2 (PW) +cobblestone1, Cobblestone 1 (PW) +cobblestone, Cobblestone (PW) +limestone, Limestone (PW) +sandstone, Sandstone (PW) +slate, Slate (PW) +granite, Granite (PW) +polishedpinkmarble, Polished Pink Marble (PW) +pinkmarble, Pink Marble (PW) +polishedgreenmarble, Polished Green Marble (PW) +greenmarble, Green Marble (PW) +polisheddarkmarble, Polished Dark Marble (PW) +darkmarble, Dark Marble (PW) +polishedbeigemarble, Polished Beige Marble (PW) +beigemarble, Beige Marble (PW) +polishedbluemarble, Polished Blue Marble (PW) +bluemarble, Blue Marble (PW) +flemishbrick, Flemish Brick (PW) +firebrick, Fire Brick (PW) +weatheredbrick, Weathered Brick (PW) +oldenglishbrick2, Old English Brick 2 (PW) +oldenglishbrick, Old English Brick (PW) +bonechina, Bone China (PW) +ceramic, Cermaic (PW) +stoneware, Stoneware (PW) +earthenware, Earthenware (PW) +porcelain, Porcelain (PW) +pavingasphalt, Paving Asphalt (PW) +pavingwetconcrete, Paving Wet Concrete (PW) +pavingdarkconcrete, Paving Dark Concrete (PW) +pavinglightconcrete, Paving Light Concrete (PW) +pavingredconcrete, Paving Red Concrete (PW) +pavingstone, Paving Stone (PW) +clothwhitecotton, Cloth White Cotton (PW) +clothbeigecotton, Cloth Beige Cotton (PW) +clothbluecotton, Cloth Blue Cotton (PW) +clothburgundycotton, Cloth Burgundy Cotton (PW) +clothgreycotton, Cloth Grey Cotton (PW) +clothcanvas, Cloth Canvas (PW) +clothburlap, Cloth Burplap (PW) +carpetcolor5, Carpet Color 5 (PW) +carpetcolor4, Carpet Color 4 (PW) +carpetcolor3, Carpet Color 3 (PW) +carpetcolor2, Carpet Color 2 (PW) +carpetcolor1, Carpet Color 1 (PW) +sandblastedglass, Sandblasted Glass (PW) +frostedglass, Frosted Glass (PW) +reflectivegreenglass, Reflective Green Glass (PW) +reflectiveblueglas, Reflective Blue Glass (PW) +reflectiveclearglass, Reflective Clear Glass (PW) +mirror, Mirror (PW) +brownglass, Brown Glass (PW) +greenglass, Green Glass (PW) +blueglass, Blue Glass (PW) +clearglass, Clear Glass (PW) +texturedrubber, Textured Rubber (PW) +perforatedrubber, Perforated Rubber (PW) +tiretreadrubber, Tire Tread Rubber (PW) +glossyrubber, Glossy Rubber (PW) +matterubber, Matte Rubber (PW) +darkpowdercoat, Dark Powdercoat (PW) +aluminumpowdercoat, Aluminum Powdercoat (PW) +blackspraypaint, Black Spray Paint (PW) +redspraypaint, Red Spray Paint (PW) +candyappleredcarpainthq, Candy Apple Red Car Paint HQ (PW) +steelgreycarpainthq, Steel Grey Car Paint HQ (PW) +sienacarpainthq, Siena Car Paint HQ (PW) +glossredcarpainthq, Gloss Red Car Paint HQ (PW) +glossbluecarpainthq, Gloss Blue Car Paint HQ (PW) +metallicgoldcarpainthq, Metallic Gold Car Paint HQ (PW) +metallicwarmgreycarpainthq, Metallic Warm Grey Car Paint HQ (PW) +metalliccoolgreycarpainthq, Metallic Cool Grey Car Paint HQ (PW) +whitecarpaint, White Car Paint (PW) +blackcarpaint, Black Car Paint (PW) +beigesatinfinishplastic, beige Satin Finish Plastic (PW) +yellowsatinfinishplastic, Yellow Satin Finish Plastic (PW) +greensatinfinishplastic, Green Satin Finish Plastic (PW) +redsatinfinishplastic, Red Satin Finish Plastic (PW) +bluesatinfinishplastic, Blue Satin Finish Plastic (PW) +blacksatinfinishplastic, Black Satin Finish Plastic (PW) +darkgreysatinfinishplastic, Dark Grey Satin Finish Plastic (PW) +lightgreysatinfinishplastic, Light Grey Satin Finish Plastic (PW) +creamsatinfinishplastic, Cream Satin Finish Plastic (PW) +whitesatinfinishplastic, White Satin Finish Plastic (PW) +beigelowglossplastic, beige Low Gloss Plastic (PW) +yellowlowglossplastic, Yellow Low Gloss Plastic (PW) +greenlowglossplastic, Green Low Gloss Plastic (PW) +redlowglossplastic, Red Low Gloss Plastic (PW) +bluelowglossplastic, Blue Low Gloss Plastic (PW) +blacklowglossplastic, Black Low Gloss Plastic (PW) +darkgreylowglossplastic, Dark Grey Low Gloss Plastic (PW) +lightgreylowglossplastic, Light Grey Low Gloss Plastic (PW) +creamlowglossplastic, Cream Low Gloss Plastic (PW) +whitelowglossplastic, White Low Gloss Plastic (PW) +beigemediumglossplastic, Beige Medium Gloss Plastic (PW) +yellowmediumglossplastic, Yellow Medium Gloss Plastic (PW) +greenmediumglossplastic, Green Medium Gloss Plastic (PW) +redmediumglossplastic, Red Medium Gloss Plastic (PW) +bluemediumglossplastic, Blue Medium Gloss Plastic (PW) +blackmediumglossplastic, Black Medium Gloss Plastic (PW) +darkgreymediumglossplastic, Dark Grey Medium Gloss Plastic (PW) +lightgreymediumglossplastic, Light Grey Medium Gloss Plastic (PW) +creammediumglossplastic, Cream Medium Gloss Plastic (PW) +whitehighglossplastic, White High Gloss Plastic (PW) +beigehighglossplastic, beige High Gloss Plastic (PW) +yellowhighglossplastic, Yellow High Gloss Plastic (PW) +greenhighglossplastic, Green High Gloss Plastic (PW) +redhighglossplastic, Red High Gloss Plastic (PW) +bluehighglossplastic, Blue High Gloss Plastic (PW) +blackhighglossplastic, Black High Gloss Plastic (PW) +darkgreyhighglossplastic, Dark Grey High Gloss Plastic (PW) +lightgreyhighglossplastic, Light Grey High Gloss Plastic (PW) +creamhighglossplastic, Cream High Gloss Plastic (PW) +whitehighglossplastic, White High Gloss Plastic (PW) +translucentplastic, Translucent Plastic (PW) +frostedplastic, Frosted Plastic (PW) +polypropyleneplastic, Polypropylene Plastic (PW) +polycarbonateplastic, Polycarbonate Plastic (PW) +largesparkerosionplasticblue, Large Spark Erosion Plasitc Blue (PW) +sparkerosionplasticblue, Spark Erosion Plasitc Blue (PW) +blueknurledplastic, Blue Knurled Plastic (PW) +bluedimpledplastic, Blue Dimpled Plastic (PW) +bluetreadplateplastic, Blue Treadplate Plastic (PW) +carbonfiberaramidfabric, Carbon Fiber Aramid Fabric (PW) +carbonfiberdesignfabric, Carbon Fiber Design Fabric (PW) +carbonfiberdyneemaplain, Carbon Fiber Dyneema Plain (PW) +carbonfiberinlayunidirectional, Carbon Fiber Inlay Unidirectional (PW) +carbonfiberepoxy, Carbon Fiber Epoxy (PW) +circularmeshplastic, Circular Mesh Plastic (PW) +diamondmeshplastic, Diamond Mesh Plastic (PW) +polishedsteel, Polished Steel (PW) +brushedsteel, Brushed Steel (PW) +satinfinishstainlesssteel, Satin Finish Stainless Steel (PW) +mattesteel, Matte Steel (PW) +sandblastedsteel, Sandblasted Steel (PW) +burnishedsteel, Burnished Steel (PW) +caststainlesssteel, Cast Stainless Steel (PW) +castcarbonsteel, Cast Carbon Steel (PW) +plaingalvanized, Plain Galvanized (PW) +chainlinksteel, Chain Link Steel (PW) +machinedsteel, Machined Steel (PW) +stainlesssteeltreadplate, Stainless Steel Treadplate (PW) +stainlesssteelknurled, Stainless Steel Knurled (PW) +wroughtstainlesssteel, Wrought Stainless Steel (PW) +chromiumplate, Chromium Plate (PW) +brushedchromium, Brushed Chromium (PW) +satinfinishchrome, Satin Finish Chrome (PW) +mattechrome, Matte Chrome (PW) +sandblastedchrome, Sandblasted Chrome (PW) +burnishedchrome, Burnished Chrome (PW) +chromiumplatecast, Chromium Plate Cast (PW) +polishedaluminum, Polished Aluminum (PW) +brushedaluminum, Brushed Aluminum (PW) +satinfinishaluminum, Satin Finish Aluminum (PW) +mattealuminum, Matte Aluminum (PW) +sandblastedaluminum, Sandblasted Aluminum (PW) +burnishedaluminum, Burnished Aluminum (PW) +castaluminum, Cast Aluminum (PW) +blueanodizedaluminum, Blue Anodized Aluminum (PW) +aluminumtreadplate, Aluminum Treadplate (PW) +polishedbronze, Polished Bronze (PW) +brushedbronze, Brushed Bronze (PW) +satinfinishbronze, Satin Finish Bronze (PW) +mattebronze, Matte Bronze (PW) +sandblastedbronze, Sandblasted Bronze (PW) +burnishedbronze, Burnished Bronze (PW) +castbronze, Cast Bronze (PW) +manganesebronze, Manganese Bronze (PW) +polishedbrass, Polished Brass (PW) +brushedbrass, Brushed Brass (PW) +satinfinishbrass, Satin Finish Brass (PW) +mattebrass, Matte Brass (PW) +sandblastedbrass, Sandblasted Brass (PW) +burnishedbrass, Burnished Brass (PW) +castbrass, Cast Brass (PW) +polishedcopper, Polished Copper (PW) +brushedcopper, Brushed Copper (PW) +satinfinishcopper, Satin Finish Copper (PW) +mattecopper, Matte Copper (PW) +sandblastedcopper, Sandblasted Copper (PW) +burnishedcopper, Burnished Copper (PW) +castcopper, Cast Copper (PW) +wroughtcopper, Wrought Copper (PW) +polishednickel, Polished Nickel (PW) +brushednickel, Brushed Nickel (PW) +satinfinishnickel, Satin Finish Nickel (PW) +mattenickel, Matte Nickel (PW) +sandblastednickel, Sandblasted Nickel (PW) +burnishednickel, Burnished Nickel (PW) +castnickel, Cast Nickel (PW) +polishedzinc, Polished Zinc (PW) +brushedzinc, Brushed Zinc (PW) +satinfinishzinc, Satin Finish Zinc (PW) +sandblastedzinc, Sandblasted Zinc (PW) +burnishedzinc, Burnished Zinc (PW) +castzinc, Cast Zinc (PW) +brushedmagnesium, Brushed Magnesium (PW) +mattemagnesium, Matte Magnesium (PW) +satinfinishmagnesium, Satin Finish Magnesium (PW) +sandblastedmagnesium, Sandblasted Magnesium (PW) +burnishedmagnesium, Burnished Magnesium (PW) +castmagnesium, Cast Magnesium (PW) +matteiron, Matte Iron (PW) +sandblastediron, Sandblasted Iron (PW) +castiron, Cast Iron (PW) +wroughtiron, Wrought Iron (PW) +brushedtitanium, Brushed Titanium (PW) +mattetitanium, Matte Titanium (PW) +satinfinishtitanium, Satin Finish Titanium (PW) +sandblastedtitanium, Sandblasted Titanium (PW) +burnishedtitanium, Burnished Titanium (PW) +casttitanium, Cast Titanium (PW) +brushedtungsten, Brushed Tungsten (PW) +matttetungsten, Mattte Tungsten (PW) +satinfinishtungsten, Satin Finish Tungsten (PW) +sandblastedtungsten, Sandblasted Tungsten (PW) +burnishedtungsten, Burnished Tungsten (PW) +casttungsten, Cast Tungsten (PW) +polishedgold, Polished Gold (PW) +mattegold, Matte Gold (PW) +satinfinishgold, Satin Finish Gold (PW) +polishedsilver, Polished Silver (PW) +mattesilver, Matte Silver (PW) +satinfinishsilver, Satin Finish Silver (PW) +polishedplatinum, Polished Platinum (PW) +matteplatinum, Matte Platinum (PW) +satinfinishplatinum, Satin Finish Platinum (PW) +mattelead, Matte Lead (PW) +sandblastedlead, Sandblasted Lead (PW) +burnishedlead, Burnished Lead (PW) +castlead, Cast Lead (PW) +brushedgalvanized, Brushed Galvanized (PW) +mattegalvanized, Matte Galvanized (PW) +coarsegalvanized, Coarse Galvanized (PW) +castgalvanized, Cast Galvanized (PW) +roughgalvanized, Rough Galvanized (PW) +shinygalvanized, Shiny Galvanized (PW) +SpecCobblestone, Cobble Stone Specular (BGE) +SpecFloorTile, Floor Tile Specular (BGE) +TransFloorTile, Floor Tile Transparent (BGE) +Aluminum1345Alloy, Aluminum 1345 Alloy +Aluminum1350Alloy, Aluminum 1350 Alloy +Aluminum2014Alloy, Aluminum 2014 Alloy +Aluminum2018Alloy, Aluminum 2018 Alloy +Aluminum2024Alloy, Aluminum 2024 Alloy +Aluminum3003Alloy, Aluminum 3003 Alloy +Aluminum6061Alloy, Aluminum 6061 Alloy +Aluminum7079Alloy, Aluminum 7079 Alloy +AluminumBrushed, Aluminum Brushed +AluminumCircularBrushed, Aluminum Circular Brushed +AluminumSatin, Aluminum Satin +BrassPolished, Brass Polished +BronzeAluminum, Bronze Aluminum +BronzeCommercial, Bronze Commercial +BronzeManganese, Bronze Manganese +BronzeTinBearing, Bronze Tin Bearing +CarbonFibre, Carbon Fiber +ceramicporcelain, Ceramic Porcelain +ceramicporcelain2, Ceramic Porcelain2 +ChromePolished, Chrome Polished +Cobalt, Cobalt +CopperPolished, Copper Polished +CopperWrought, Copper Wrought +Glass, Glass +GlassFibre, Glass Fiber +GoldDull, Gold +IronCast, Iron Cast +IronDuctile, Iron Ductile +IronMalleableCast, Iron Malleable Cast +LeadPure, Lead Pure +Molybdenum, Molybdenum +AlloyMagnesium, Magnesium Alloy +AlloyMonel400, Monel 400 +NickelBrushed, Nickel Brushed +NickelPolished, Nickel Polished +PlasticAcrylic, Plastic Acrylic +PlasticDimpled, Plastic Dimpled +PlasticMT11010, Plastic MT11010 +PlasticMT11020, Plastic MT11020 +PlasticMT11030, Plastic MT11030 +PlasticMT11040, Plastic MT11040 +PlasticMT11050, Plastic MT11050 +PlasticMT11060, Plastic MT11060 +PlasticMT11070, Plastic MT11070 +PlasticMT11080, Plastic MT11080 +PlasticMT11090, Plastic MT11090 +PlasticMT11100, Plastic MT11100 +PlasticMT11110, Plastic MT11110 +PlasticMT11120, Plastic MT11120 +PlasticMT11130, Plastic MT11130 +PlasticMT11140, Plastic MT11140 +PlasticMT11150, Plastic MT11150 +PlasticMT11155, Plastic MT11155 +PlasticMT11200, Plastic MT11200 +PlasticMT11205, Plastic MT11205 +PlasticMT11215, Plastic MT11215 +PlasticMT11230, Plastic MT11230 +PlasticMT11235, Plastic MT11235 +PlasticMT11240, Plastic MT11240 +PlasticMT11245, Plastic MT11245 +PlasticMT11250, Plastic MT11250 +PlasticNylon, Plastic Nylon +PlasticShiny, Plastic Shiny +PlasticPerspex, Plastic Perspex +PlasticPTFE, Plastic PTFE +PlasticPolycarbonate, Plastic Polycarbonate +PlasticPolypropylene, Plastic Polypropylene +PlasticPolystyrene, Plastic Polystyrene +PlasticPVC, Plastic PVC +RubberSoft, Rubber Soft +Silicon, Silicon +SiliconDioxide, Silicon Dioxide +silverpure, Silver Pure +StainlessSteel, Steel Stainless +SteelAISI304, Steel AISI 304 +SteelAISI1020, Steel AISI 1020 +SteelCastCarbon, Steel Cast Carbon +SteelCastStainless, Steel Cast Stainless +SteelGalvanised, Steel Galvanised +SteelPlainCarbon, Steel Plain Carbon +SteelSatin, Steel Satin +SteelWroughtStainless, Steel Wrought Stainless +Titanium, Titanium +Tungsten, Tungsten +Vanadium, Vanadium +water, Water +WoodBeech, Wood Beech +WoodMahogany, Wood Mahogany +WoodMaple, Wood Maple +WoodOak, Wood Oak +WoodPine, Wood Pine +WoodRedCedar, Wood Red Cedar +WoodTeak, Wood Teak +Zirconium, Zirconium \ No newline at end of file diff --git a/assets/solidworks/materials/en-US/solidworks materials.sldmat b/assets/solidworks/materials/en-US/solidworks materials.sldmat new file mode 100644 index 0000000..e492c7b Binary files /dev/null and b/assets/solidworks/materials/en-US/solidworks materials.sldmat differ diff --git a/assets/solidworks/materials/en-US/sustainability extras.sldmat b/assets/solidworks/materials/en-US/sustainability extras.sldmat new file mode 100644 index 0000000..45a3ad0 Binary files /dev/null and b/assets/solidworks/materials/en-US/sustainability extras.sldmat differ diff --git a/assets/solidworks/materials/zh-CN/SolidWorks DIN Materials.sldmat b/assets/solidworks/materials/zh-CN/SolidWorks DIN Materials.sldmat new file mode 100644 index 0000000..d222ec6 Binary files /dev/null and b/assets/solidworks/materials/zh-CN/SolidWorks DIN Materials.sldmat differ diff --git a/assets/solidworks/materials/zh-CN/shader_mapping.dat b/assets/solidworks/materials/zh-CN/shader_mapping.dat new file mode 100644 index 0000000..93cad00 --- /dev/null +++ b/assets/solidworks/materials/zh-CN/shader_mapping.dat @@ -0,0 +1,399 @@ +leather, Ƥ(PW) +sponge, (PW) +grass, (PW) +sand, ɳ (PW) +skin, Ƥ (PW) +heavyclouds, (PW) +lightclouds, (PW) +clearsky, (PW) +waterstill, ˮ (PW) +orientedstrandboard, Ť (OSB) (PW) +hardwoodfloor, Ӳľذ (PW) +hardwoodfloor2, Ӳľذ 2 (PW) +hardwoodfloor3, Ӳľذ 3 (PW) +laminatefloor, ״ذ (PW) +laminatefloor2, ״ذ 2 (PW) +laminatefloor3, ״ذ 3 (PW) +polishedash, ׹ľ (PW) +satinfinishash, ׹ľ (PW) +unfinishedash, ľ (PW) +polishedbeech, ׹ɽë (PW) +satinfinishbeech, ׹ɽë (PW) +unfinishedbeech, ɽë (PW) +polishedbirch, ׹ľ (PW) +satinfinishbirch, ׹ľ (PW) +unfinishedbirch, ľ (PW) +polishedcherry, ׹ӣľ (PW) +satinfinishcherry, ׹ӣľ (PW) +unfinishedcherry, ӣľ (PW) +polishedmahogany, ׹ľ (PW) +satinfinishmahogany, ׹ľ (PW) +unfinishedmahogany, ƺľ (PW) +polishedmaple, ׹ľ (PW) +satinfinishmaple, ׹ľ (PW) +unfinishedmaple, ľ (PW) +polishedoak, ׹ľ (PW) +satinfinishoak, ׹ľ (PW) +unfinishedoak, ľ (PW) +polishedpine, ׹ľ (PW) +satinfinishpine, ׹ľ (PW) +unfinishedpine, ľ (PW) +polishedrosewood, ׹ľ (PW) +satinfinishrosewood, ׹ľ (PW) +unfinishedrosewood, ľ (PW) +polishedsatinwood, ׹ľ (PW) +satinfinishsatinwood, ׹ľ (PW) +unfinishedsatinwood, ľ (PW) +polishedspruce, ׹ɼ (PW) +satinfinishspruce, ׹ɼ (PW) +unfinishedspruce, ɼ (PW) +polishedteak, ׹ľ (PW) +satinfinishteak, ׹ľ (PW) +unfinishedteak, ľ (PW) +polishedwalnut, ׹ľ (PW) +satinfinishwalnut, ׹ľ (PW) +unfinishedwalnut, ƺľ (PW) +redceramictile, ש (PW) +floortile7, ذש 7 (PW) +floortile6, ذש 6 (PW) +floortile5, ذש 5 (PW) +floortile4, ذש 4 (PW) +floortile3, ذש 3 (PW) +floortile2, ذש 2 (PW) +floortile1, ذש 1 (PW) +cobblestone4, ʯ 4 (PW) +cobblestone3, ʯ 3 (PW) +cobblestone2, ʯ 2 (PW) +cobblestone1, ʯ 1 (PW) +cobblestone, ʯ (PW) +limestone, ʯʯ (PW) +sandstone, ɰ (PW) +slate, ʯ (PW) +granite, (PW) +polishedpinkmarble, ׹ۺʯ (PW) +pinkmarble, ۺʯ (PW) +polishedgreenmarble, ׹ɫʯ (PW) +greenmarble, ɫʯ (PW) +polisheddarkmarble, ׹ɫʯ (PW) +darkmarble, ɫʯ (PW) +polishedbeigemarble, ׹ɫʯ (PW) +beigemarble, ɫʯ (PW) +polishedbluemarble, ׹ɫʯ (PW) +bluemarble, ɫʯ (PW) +flemishbrick, äӲש (PW) +firebrick, ͻש (PW) +weatheredbrick, 绯ʯ (PW) +oldenglishbrick2, ʽӢӲש 2 (PW) +oldenglishbrick, ʽӢӲש (PW) +bonechina, ʴ (PW) +ceramic, մ (PW) +stoneware, մ (PW) +earthenware, (PW) +porcelain, (PW) +pavingasphalt, · (PW) +pavingwetconcrete, ·ʪ (PW) +pavingdarkconcrete, ·ڻ (PW) +pavinglightconcrete, · (PW) +pavingredconcrete, · (PW) +pavingstone, ·ʯ (PW) +clothwhitecotton, ޲ (PW) +clothbeigecotton, ɫ޲ (PW) +clothbluecotton, ɫ޲ (PW) +clothburgundycotton, Ѿɫ޲ (PW) +clothgreycotton, ɫ޲ (PW) +clothcanvas, (PW) +clothburlap, 鲼 (PW) +carpetcolor5, ̺ɫ 5 (PW) +carpetcolor4, ̺ɫ 4 (PW) +carpetcolor3, ̺ɫ 3 (PW) +carpetcolor2, ̺ɫ 2 (PW) +carpetcolor1, ̺ɫ 1 (PW) +sandblastedglass, ɳ (PW) +frostedglass, ë (PW) +reflectivegreenglass, ̲ (PW) +reflectiveblueglas, (PW) +reflectiveclearglass, (PW) +mirror, (PW) +brownglass, ز (PW) +greenglass, ̲ (PW) +blueglass, (PW) +clearglass, ͸ (PW) +texturedrubber, ṹ (PW) +perforatedrubber, (PW) +tiretreadrubber, ̥ (PW) +glossyrubber, (PW) +matterubber, ޹ (PW) +darkpowdercoat, ɫ۲(PW) +aluminumpowdercoat, ۲ (PW) +blackspraypaint, ɫ (PW) +redspraypaint, ɫ (PW) +candyappleredcarpainthq, ʺƻɫ (PW) +steelgreycarpainthq, ʸֻɫ (PW) +sienacarpainthq, ɫ (PW) +glossredcarpainthq, ʹɫ (PW) +glossbluecarpainthq, ʹɫ (PW) +metallicgoldcarpainthq, ʽɫ (PW) +metallicwarmgreycarpainthq, ʽȻɫ (PW) +metalliccoolgreycarpainthq, ʽɫ (PW) +whitecarpaint, ɫ (PW) +blackcarpaint, ɫ (PW) +beigesatinfinishplastic, ɫ׹ (PW) +yellowsatinfinishplastic, ɫ׹ (PW) +greensatinfinishplastic, ɫ׹ (PW) +redsatinfinishplastic, ɫ׹ (PW) +bluesatinfinishplastic, ɫ׹ (PW) +blacksatinfinishplastic, ɫ׹ (PW) +darkgreysatinfinishplastic, ɫ׹ (PW) +lightgreysatinfinishplastic, ɫ׹ (PW) +creamsatinfinishplastic, ɫ׹ (PW) +whitesatinfinishplastic, ɫ׹ (PW) +beigelowglossplastic, ɫ͹ (PW) +yellowlowglossplastic, ɫ͹ (PW) +greenlowglossplastic, ɫ͹ (PW) +redlowglossplastic, ɫ͹ (PW) +bluelowglossplastic, ɫ͹ (PW) +blacklowglossplastic, ɫ͹ (PW) +darkgreylowglossplastic, ɫ͹ (PW) +lightgreylowglossplastic, ɫ͹ (PW) +creamlowglossplastic, ɫ͹ (PW) +whitelowglossplastic, ɫ͹ (PW) +beigemediumglossplastic, ɫй (PW) +yellowmediumglossplastic, ɫй (PW) +greenmediumglossplastic, ɫй (PW) +redmediumglossplastic, ɫй (PW) +bluemediumglossplastic, ɫй (PW) +blackmediumglossplastic, ɫй (PW) +darkgreymediumglossplastic, ɫй (PW) +lightgreymediumglossplastic, ɫй (PW) +creammediumglossplastic, ɫй (PW) +whitemediumglossplastic, ɫй (PW) +beigehighglossplastic, ɫ߹ (PW) +yellowhighglossplastic, ɫ߹ (PW) +greenhighglossplastic, ɫ߹ (PW) +redhighglossplastic, ɫ߹ (PW) +bluehighglossplastic, ɫ߹ (PW) +blackhighglossplastic, ɫ߹ (PW) +darkgreyhighglossplastic, ɫ߹ (PW) +lightgreyhighglossplastic, ɫ߹ (PW) +creamhighglossplastic, ɫ߹ (PW) +whitehighglossplastic, ɫ߹ (PW) +translucentplastic, ͸ (PW) +frostedplastic, ë (PW) +polypropyleneplastic, ۱ϩ (PW) +polycarbonateplastic, ̼ (PW) +largesparkerosionplasticblue, ɫʴ (PW) +sparkerosionplasticblue, ɫʴ (PW) +blueknurledplastic, (PW) +bluedimpledplastic, (PW) +bluetreadplateplastic, ̤ (PW) +carbonfiberaramidfabric, ̼ά᰷֯ (PW) +carbonfiberdesignfabric, ̼άװ֯ (PW) +carbonfiberdyneemaplain, ̼άͨ (PW) +carbonfiberinlayunidirectional, ̼ά (PW) +carbonfiberepoxy, ̼ά˹ (PW) +circularmeshplastic, Բ (PW) +diamondmeshplastic, (PW) +polishedsteel, ׹ (PW) +brushedsteel, Ϳˢ (PW) +satinfinishstainlesssteel, ׹ⲻ (PW) +mattesteel, ޹ (PW) +sandblastedsteel, ɳ (PW) +burnishedsteel, ĥ (PW) +caststainlesssteel, 첻 (PW) +castcarbonsteel, ̼ (PW) +plaingalvanized, ͨƸ (PW) +chainlinksteel, (PW) +machinedsteel, ӹ (PW) +stainlesssteeltreadplate, ̤ (PW) +stainlesssteelknurled, ֹ (PW) +wroughtstainlesssteel, Ʋ (PW) +chromiumplate, Ƹ (PW) +brushedchromium, Ϳˢ (PW) +satinfinishchrome, ׹ (PW) +mattechrome, ޹ (PW) +sandblastedchrome, ɳ (PW) +burnishedchrome, ĥ (PW) +chromiumplatecast, Ƹģ (PW) +polishedaluminum, ׹ (PW) +brushedaluminum, Ϳˢ (PW) +satinfinishaluminum, ׹ (PW) +mattealuminum, ޹ (PW) +sandblastedaluminum, ɳ (PW) +burnishedaluminum, ĥ (PW) +castaluminum, (PW) +blueanodizedaluminum, ɫ (PW) +aluminumtreadplate, ̤ (PW) +polishedbronze, ׹ͭ (PW) +brushedbronze, Ϳˢͭ (PW) +satinfinishbronze, ׹ͭ (PW) +mattebronze, ޹ͭ (PW) +sandblastedbronze, ɳͭ (PW) +burnishedbronze, ĥͭ (PW) +castbronze, ͭ (PW) +manganesebronze, ͭ (PW) +polishedbrass, ׹ͭ (PW) +brushedbrass, Ϳˢͭ (PW) +satinfinishbrass, ׹ͭ (PW) +mattebrass, ޹ͭ (PW) +sandblastedbrass, ɳͭ (PW) +burnishedbrass, ĥͭ (PW) +castbrass, ͭ (PW) +polishedcopper, ׹ͭ (PW) +brushedcopper, Ϳˢͭ (PW) +satinfinishcopper, ׹ͭ (PW) +mattecopper, ޹ͭ (PW) +sandblastedcopper, ɳͭ (PW) +burnishedcopper, ĥͭ (PW) +castcopper, ͭ (PW) +wroughtcopper, ƺͭ (PW) +polishednickel, ׹ (PW) +brushednickel, Ϳˢ (PW) +satinfinishnickel, ׹ (PW) +mattenickel, ޹ (PW) +sandblastednickel, ɳ (PW) +burnishednickel, ĥ (PW) +castnickel, (PW) +polishedzinc, ׹п (PW) +brushedzinc, Ϳˢп (PW) +satinfinishzinc, ׹п (PW) +sandblastedzinc, ɳп (PW) +burnishedzinc, ĥп (PW) +castzinc, п (PW) +brushedmagnesium, Ϳˢþ (PW) +mattemagnesium, ޹þ (PW) +satinfinishmagnesium, ׹þ (PW) +sandblastedmagnesium, ɳþ (PW) +burnishedmagnesium, ĥþ (PW) +castmagnesium, þ (PW) +matteiron, ޹ (PW) +sandblastediron, ɳ (PW) +castiron, (PW) +wroughtiron, (PW) +brushedtitanium, Ϳˢ (PW) +mattetitanium, ޹ (PW) +satinfinishtitanium, ׹ (PW) +sandblastedtitanium, ɳ (PW) +burnishedtitanium, ĥ (PW) +casttitanium, (PW) +brushedtungsten, Ϳˢ (PW) +matttetungsten, ޹ (PW) +satinfinishtungsten, ׹ (PW) +sandblastedtungsten, ɳ (PW) +burnishedtungsten, ĥ (PW) +casttungsten, (PW) +polishedgold, ׹ (PW) +mattegold, ޹ (PW) +satinfinishgold, ׹ (PW) +polishedsilver, ׹ (PW) +mattesilver, ޹ (PW) +satinfinishsilver, ׹ (PW) +polishedplatinum, ׹׽ (PW) +matteplatinum, ޹׽ (PW) +satinfinishplatinum, ׹׽ (PW) +mattelead, ޹Ǧ (PW) +sandblastedlead, ɳǦ (PW) +burnishedlead, ĥǦ (PW) +castlead, Ǧ (PW) +brushedgalvanized, Ϳˢ (PW) +mattegalvanized, ޹ (PW) +coarsegalvanized, (PW) +castgalvanized, (PW) +roughgalvanized, ֲڵ (PW) +shinygalvanized, (PW) +SpecCobblestone, ʯ (BGE) +SpecFloorTile, ذש (BGE) +TransFloorTile, ͸ذש (BGE) +Aluminum1345Alloy, 1345 Ͻ +Aluminum1350Alloy, 1350 Ͻ +Aluminum2014Alloy, 2014 Ͻ +Aluminum2018Alloy, 2018 Ͻ +Aluminum2024Alloy, 2024 Ͻ +Aluminum3003Alloy, 3003 Ͻ +Aluminum6061Alloy, 6061 Ͻ +Aluminum7079Alloy, 7079 Ͻ +AluminumBrushed, Ϳˢ +AluminumCircularBrushed, ԲͿˢ +AluminumSatin, +BrassPolished, ׹ͭ +BronzeAluminum, ͭ +BronzeCommercial, ҵͭ +BronzeManganese, ͭ +BronzeTinBearing, ͭ +CarbonFibre, ̼ά +ceramicporcelain, մɴ +ceramicporcelain2, մɴ2 +ChromePolished, ׹ +Cobalt, +CopperPolished, ׹ͭ +CopperWrought, ƺͭ +Glass, +GlassFibre, ά +GoldDull, +IronCast, +IronDuctile, +IronMalleableCast, ɶ +LeadPure, Ǧ +Molybdenum, +AlloyMagnesium, þϽ +AlloyMonel400, Monel 400 +NickelBrushed, Ϳˢ +NickelPolished, ׹ +PlasticAcrylic, ϩ +PlasticDimpled, +PlasticMT11010, MT11010 +PlasticMT11020, MT11020 +PlasticMT11030, MT11030 +PlasticMT11040, MT11040 +PlasticMT11050, MT11050 +PlasticMT11060, MT11060 +PlasticMT11070, MT11070 +PlasticMT11080, MT11080 +PlasticMT11090, MT11090 +PlasticMT11100, MT11100 +PlasticMT11110, MT11110 +PlasticMT11120, MT11120 +PlasticMT11130, MT11130 +PlasticMT11140, MT11140 +PlasticMT11150, MT11150 +PlasticMT11155, MT11155 +PlasticMT11200, MT11200 +PlasticMT11205, MT11205 +PlasticMT11215, MT11215 +PlasticMT11230, MT11230 +PlasticMT11235, MT11235 +PlasticMT11240, MT11240 +PlasticMT11245, MT11245 +PlasticMT11250, MT11250 +PlasticNylon, +PlasticShiny, +PlasticPerspex, л +PlasticPTFE, PTFE +PlasticPolycarbonate, ̼֬ +PlasticPolypropylene, ۱ϩ +PlasticPolystyrene, ۱ϩ +PlasticPVC, ϩ +RubberSoft, +Silicon, +SiliconDioxide, +silverpure, +StainlessSteel, +SteelAISI304, AISI 304 +SteelAISI1020, AISI 1020 +SteelCastCarbon, ̼ +SteelCastStainless, 첻 +SteelGalvanised, Ƹ +SteelPlainCarbon, ̼ͨ +SteelSatin, и +SteelWroughtStainless, 첻 +Titanium, +Tungsten, +Vanadium, +water, ˮ +WoodBeech, ɽëľ +WoodMahogany, ľ +WoodMaple, ľ +WoodOak, ľ +WoodPine, ľ +WoodRedCedar, ѩľ +WoodTeak, ľ +Zirconium, \ No newline at end of file diff --git a/assets/solidworks/materials/zh-CN/solidworks materials.sldmat b/assets/solidworks/materials/zh-CN/solidworks materials.sldmat new file mode 100644 index 0000000..300d263 Binary files /dev/null and b/assets/solidworks/materials/zh-CN/solidworks materials.sldmat differ diff --git a/assets/solidworks/materials/zh-CN/sustainability extras.sldmat b/assets/solidworks/materials/zh-CN/sustainability extras.sldmat new file mode 100644 index 0000000..75bc471 Binary files /dev/null and b/assets/solidworks/materials/zh-CN/sustainability extras.sldmat differ diff --git a/bearing_bracket.stl b/bearing_bracket.stl new file mode 100644 index 0000000..ad71fc1 Binary files /dev/null and b/bearing_bracket.stl differ diff --git a/build-bearing-bracket.mjs b/build-bearing-bracket.mjs new file mode 100644 index 0000000..394b5d7 --- /dev/null +++ b/build-bearing-bracket.mjs @@ -0,0 +1,99 @@ +import fs from 'node:fs/promises'; +import { initGeometry } from './js/geometry.js'; +import { Doc } from './js/doc.js'; + +await initGeometry(); + +const doc = new Doc(); +doc.name = '轴承支座_160x70_φ68_φ40H7'; +doc.units = 'mm'; + +// Named dimensions are kept in the document for editing and inspection. +for (const [name, expression] of [ + ['BaseLength', '160'], ['BaseWidth', '70'], ['BaseHeight', '14'], ['BaseCornerR', '12'], + ['MountHoleDia', '13'], ['MountHoleX', '60'], ['MountHoleY', '15'], + ['StandThicknessX', '26'], ['StandWidthY', '46'], ['StandHeight', '22'], + ['BossOuterDia', '68'], ['BossBoreDia', '40'], ['BossLengthX', '60'], ['BossCenterZ', '70'], + ['BoreChamfer', '2'], ['RibThickness', '8'], +]) doc.setDimension(name, expression); + +function sketch(plane, offset, name) { + return doc.addSketch({ plane, offset, name }); +} +function line(sk, p1, p2) { + doc.addEntity(sk.id, { type: 'line', p1, p2 }); +} +function arc(sk, c, r, a0, a1) { + doc.addEntity(sk.id, { type: 'arc', c, r, a0, a1 }); +} +function circle(sk, c, r) { + doc.addEntity(sk.id, { type: 'circle', c, r }); +} +function feature(type, name, params, sketchId = null) { + return doc.addFeature({ type, name, params, sketchId }); +} + +// 1. 160 x 70 x 14 base plate, true R12 corner arcs. +const base = sketch('front', 0, '底板外轮廓_R12'); +line(base, [-68, -35], [68, -35]); +arc(base, [68, -23], 12, -Math.PI / 2, 0); +line(base, [80, -23], [80, 23]); +arc(base, [68, 23], 12, 0, Math.PI / 2); +line(base, [68, 35], [-68, 35]); +arc(base, [-68, 23], 12, Math.PI / 2, Math.PI); +line(base, [-80, 23], [-80, -23]); +arc(base, [-68, -23], 12, Math.PI, 3 * Math.PI / 2); +feature('extrude', '底板_160x70x14', { depth: 14 }, base.id); + +// 2. Four Ø13 through holes at ±60, ±15. +const holes = sketch('front', 0, '四个安装孔_孔距120x30'); +for (const x of [-60, 60]) for (const y of [-15, 15]) circle(holes, [x, y], 6.5); +feature('cut', '四个φ13安装通孔_边距20', { throughAll: true, mode: 'throughAll' }, holes.id); + +// 3. Central upright plate, z=14 to z=36. +const stand = sketch('front', 14, '立板_26x46'); +doc.addEntity(stand.id, { type: 'rect', c: [0, 0], w: 26, h: 46 }); +feature('extrude', '立板_厚26_宽46_高22', { depth: 22 }, stand.id); + +// 4. Hollow boss along X: x=-30..30, center (y,z)=(0,70). +const boss = sketch('right', -30, '圆筒座_φ68_φ40'); +circle(boss, [70, 0], 34); +circle(boss, [70, 0], 20); +feature('extrude', '圆筒座_外φ68_内φ40_长60', { depth: 60 }, boss.id); + +// 5. C2 inner-bore chamfers. The extended cylinder primitive is used as a +// conical cutting tool, with the large radius at each outside end. +feature('cylcut', '内孔左端_C2', { r: 22, r2: 20, h: 2, axis: 'x', tx: -29, tz: 70 }); +feature('cylcut', '内孔右端_C2', { r: 20, r2: 22, h: 2, axis: 'x', tx: 29, tz: 70 }); + +// 6. Two triangular gussets, front and rear of the upright plate. +// The 8 mm extrusion is along Y; the apex intentionally penetrates the boss +// slightly so the Boolean union is robust while remaining tangent in design. +const ribFront = sketch('top', -27, '前加强筋_三角截面'); +line(ribFront, [-13, 14], [0, 14]); +line(ribFront, [0, 14], [13, 14]); +line(ribFront, [13, 14], [0, 66]); +line(ribFront, [0, 66], [-13, 14]); +feature('extrude', '前加强筋_厚8', { depth: 8 }, ribFront.id); + +const ribRear = sketch('top', 19, '后加强筋_三角截面'); +line(ribRear, [-13, 14], [0, 14]); +line(ribRear, [0, 14], [13, 14]); +line(ribRear, [13, 14], [0, 66]); +line(ribRear, [0, 66], [-13, 14]); +feature('extrude', '后加强筋_厚8', { depth: 8 }, ribRear.id); + +doc.rebuild(); +const errors = doc.features.filter(f => f.error).map(f => ({ name: f.name, error: f.error })); +if (errors.length) throw new Error(JSON.stringify(errors)); + +const bbox = doc.bbox; +const summary = { + bbox: { min: bbox.min, max: bbox.max }, + volume: doc.bodyManifold.volume(), + sketches: doc.sketches.length, + features: doc.features.length, + featureNames: doc.features.map(f => f.name), +}; +console.log(JSON.stringify(summary, null, 2)); +await fs.writeFile('./轴承支座_160x70_φ68_φ40H7.json', JSON.stringify(doc.toJSON(), null, 2), 'utf8'); diff --git a/css/style.css b/css/style.css index 1f68b95..20c72d4 100644 --- a/css/style.css +++ b/css/style.css @@ -4,16 +4,30 @@ --fg:#d7dce3; --fg-dim:#8b94a1; --fg-bright:#fff; --accent:#3d8bff; --accent2:#5ea3ff; --warn:#ffb454; --err:#ff6b6b; --ok:#5fd08a; --sketch:#ffd54a; --sel:#7fb8ff; - --ribbon-h:86px; --status-h:26px; + --chrome-h:30px; --ribbon-h:86px; --status-h:28px; } *{box-sizing:border-box; margin:0; padding:0} html,body{height:100%; overflow:hidden} body{background:var(--bg); color:var(--fg); font:13px/1.45 "Microsoft YaHei", "Segoe UI", sans-serif; user-select:none} #app{display:flex; flex-direction:column; height:100%} +/* ---------- application chrome ---------- */ +#app-chrome{height:var(--chrome-h); display:flex; align-items:center; gap:14px; padding:0 10px; background:#1a1e23; border-bottom:1px solid #11151a; color:var(--fg-dim); flex-shrink:0; font-size:11px} +.chrome-brand{display:flex; align-items:center; gap:6px; color:var(--fg); font-weight:700; letter-spacing:.6px; min-width:84px} +.chrome-mark{display:inline-grid; place-items:center; width:18px; height:18px; border-radius:4px; background:linear-gradient(135deg,#5ea3ff,#2f6fd6); color:#fff; font-size:11px; box-shadow:0 1px 4px rgba(0,0,0,.35)} +.chrome-context{display:flex; align-items:center; gap:8px; min-width:0} +.chrome-doc{color:var(--fg); max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap} +.chrome-sep{color:var(--line2)} +.chrome-mode{color:var(--accent2)} +.chrome-state{padding:2px 7px; border:1px solid #2e5c40; border-radius:9px; color:#a5e8c0; background:#213a2c} +.chrome-state.err{border-color:#7a3a34; color:#ffb4ae; background:#3a2424} +.chrome-actions{display:flex; align-items:center; gap:6px; margin-left:auto; white-space:nowrap} +.chrome-status-dot{width:7px; height:7px; border-radius:50%; background:var(--ok); box-shadow:0 0 0 3px rgba(95,208,138,.12)} +.chrome-status-label{color:var(--fg-dim)} button{font-family:inherit; color:var(--fg); background:var(--panel2); border:1px solid var(--line); border-radius:5px; cursor:pointer} button:hover{background:var(--panel3); border-color:var(--line2)} button.active{background:#27456e; border-color:var(--accent)} button:disabled{opacity:.4; cursor:default} +button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent2); outline-offset:1px} input,textarea,select{font-family:inherit; color:var(--fg); background:#1a1e24; border:1px solid var(--line); border-radius:4px; padding:4px 6px; outline:none} input:focus,textarea:focus,select:focus{border-color:var(--accent)} input[type=number]{width:90px} @@ -47,8 +61,8 @@ input[type=number]{width:90px} /* ---------- workspace ---------- */ #workspace{flex:1; display:flex; min-height:0} -#tree-panel{width:210px; background:var(--panel); border-right:1px solid var(--line); display:flex; flex-direction:column; flex-shrink:0} -#props-panel{width:280px; background:var(--panel); border-left:1px solid var(--line); display:flex; flex-direction:column; flex-shrink:0; overflow-y:auto} +#tree-panel{width:230px; background:var(--panel); border-right:1px solid var(--line); display:flex; flex-direction:column; flex-shrink:0} +#props-panel{width:300px; background:var(--panel); border-left:1px solid var(--line); display:flex; flex-direction:column; flex-shrink:0; overflow-y:auto} /* ---------- resizer ---------- */ .resizer{position:relative; flex-shrink:0; z-index:15} @@ -56,8 +70,9 @@ input[type=number]{width:90px} .resizer-h:hover, .resizer-h.dragging{background:var(--accent)} .resizer-v{height:4px; cursor:row-resize; background:var(--line); transition:background .15s} .resizer-v:hover, .resizer-v.dragging{background:var(--accent)} -.panel-title{padding:8px 10px; font-size:12px; color:var(--fg-dim); border-bottom:1px solid var(--line); background:var(--panel2); flex-shrink:0} +.panel-title{display:flex; align-items:center; gap:0; min-height:36px; padding:8px 12px; font-size:12px; color:var(--fg); border-bottom:1px solid var(--line); background:linear-gradient(180deg,#303640,#2b3038); flex-shrink:0; font-weight:600} .panel-sub{color:var(--line2); margin-left:6px} +.panel-meta{margin-left:auto; color:var(--fg-dim); font-size:10px; font-weight:400; border:1px solid var(--line); border-radius:9px; padding:1px 6px; background:rgba(0,0,0,.14)} #tree{flex:1; overflow-y:auto; padding:6px 4px} #props{flex:1; padding:10px} @@ -76,8 +91,11 @@ input[type=number]{width:90px} .prop-sec{font-size:11px; color:var(--fg-dim); margin:12px 0 6px; text-transform:uppercase; letter-spacing:.5px} .prop-row{display:flex; align-items:center; gap:8px; margin:6px 0} .prop-row label{width:64px; color:var(--fg-dim); font-size:12px; flex-shrink:0} -.prop-row .val{flex:1} +.prop-row .val{flex:1; min-width:0; overflow-wrap:anywhere; word-break:break-word} .prop-note{font-size:11px; color:var(--fg-dim); line-height:1.5; margin:6px 0} +.prop-note{max-width:100%; overflow-wrap:anywhere; word-break:break-word; white-space:pre-wrap} +.prop-note.prop-error{color:#ffb4ae;border-left:2px solid var(--err);padding-left:8px} +.prop-note.prop-ok{color:#a5e8c0;border-left:2px solid var(--ok);padding-left:8px} .prop-btns{display:flex; gap:8px; margin-top:12px} .prop-btns button{flex:1; padding:6px} .btn-ok{background:linear-gradient(180deg,#3d8bff,#2f6fd6); border-color:#2459a8; color:#fff} @@ -95,8 +113,17 @@ input[type=number]{width:90px} /* ---------- viewport ---------- */ #viewport-wrap{flex:1; position:relative; min-width:0; background:#15181d} #viewport{position:absolute; inset:0} +#drawing-preview{position:absolute; inset:18px; z-index:8; display:flex; align-items:center; justify-content:center; background:#20252b; border:1px solid var(--line); border-radius:8px; overflow:auto; box-shadow:0 10px 28px rgba(0,0,0,.25)} +#drawing-preview svg{width:min(92%,900px); height:auto; max-height:94%; background:#fff; box-shadow:0 4px 18px rgba(0,0,0,.35)} +#viewport-wrap:after{content:""; position:absolute; inset:0; pointer-events:none; border:1px solid rgba(255,255,255,.025)} +#empty-state{position:absolute; z-index:5; left:50%; top:50%; transform:translate(-50%,-46%); width:min(390px,calc(100% - 44px)); padding:24px 26px 22px; text-align:center; border:1px solid var(--line); border-radius:12px; background:rgba(29,34,41,.9); box-shadow:0 16px 40px rgba(0,0,0,.28); backdrop-filter:blur(7px)} +.empty-glyph{width:44px; height:44px; margin:0 auto 10px; display:grid; place-items:center; color:var(--accent2); border:1px solid #3f6594; border-radius:12px; background:#1d2c47; font-size:27px; line-height:1} +.empty-title{font-size:16px; color:var(--fg-bright); font-weight:700} +.empty-copy{margin:7px auto 16px; max-width:300px; color:var(--fg-dim); font-size:12px; line-height:1.6} +.empty-actions{display:flex; justify-content:center; gap:8px} +.empty-actions button{padding:6px 12px; font-size:12px} #viewport-hud{position:absolute; left:10px; top:10px; pointer-events:none; color:var(--fg-dim); font-size:12px; display:flex; flex-direction:column; gap:4px} -#viewport-hud .hud-line{background:rgba(20,24,30,.7); padding:2px 8px; border-radius:4px; backdrop-filter:blur(4px)} +#viewport-hud .hud-line{background:rgba(20,24,30,.78); padding:4px 9px; border:1px solid rgba(69,78,91,.7); border-radius:5px; backdrop-filter:blur(4px)} #viewport-hud .hud-sub{font-size:11px; color:var(--fg-dim)} #viewport-hud .hud-snaptip{color:var(--warn)} @@ -112,20 +139,23 @@ input[type=number]{width:90px} .minisw-label.snapmark{pointer-events:none; color:#7fb8ff; font-size:13px; background:transparent; border:none} /* ---------- status bar ---------- */ -#statusbar{height:var(--status-h); background:var(--panel); border-top:1px solid var(--line); display:flex; align-items:center; gap:16px; padding:0 10px; font-size:12px; color:var(--fg-dim); flex-shrink:0} +#statusbar{height:var(--status-h); background:#20252b; border-top:1px solid var(--line); display:flex; align-items:center; gap:16px; padding:0 10px; font-size:11px; color:var(--fg-dim); flex-shrink:0} #statusbar .sb-item{white-space:nowrap} #statusbar .sb-tip{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap} #statusbar .sb-snap{color:var(--warn)} +.sb-item strong{font-weight:600} /* ---------- AI panel ---------- */ -#ai-panel{position:absolute; right:12px; bottom:12px; width:400px; max-height:70%; background:var(--panel); border:1px solid var(--line2); border-radius:10px; box-shadow:0 8px 30px rgba(0,0,0,.5); display:flex; flex-direction:column; z-index:30; overflow:hidden} +#ai-panel{position:absolute; right:14px; bottom:14px; width:min(430px,calc(100% - 28px)); max-height:72%; background:var(--panel); border:1px solid var(--line2); border-radius:10px; box-shadow:0 8px 30px rgba(0,0,0,.5); display:flex; flex-direction:column; z-index:30; overflow:hidden} #ai-panel.hidden{display:none} .ai-header{display:flex; align-items:center; gap:8px; padding:8px 12px; background:var(--panel2); border-bottom:1px solid var(--line); cursor:move; user-select:none} .ai-title{font-weight:700; color:var(--accent2)} -.ai-mode-badge{font-size:11px; background:#1d2c47; color:#9ecbff; border:1px solid #23466e; padding:1px 8px; border-radius:10px} +.ai-mode-badge{font-size:11px; background:#1d2c47; color:#9ecbff; border:1px solid #23466e; padding:1px 8px; border-radius:10px; cursor:pointer} .ai-mode-badge.llm{background:#3a2c1d; color:#ffd9a0; border-color:#6e5523} +.ai-mode-badge:focus-visible{outline:2px solid var(--accent2); outline-offset:2px} .ai-close{margin-left:auto; background:none; border:none; font-size:16px; color:var(--fg-dim); padding:0 4px} .ai-close:hover{color:var(--fg)} +.ai-guide{padding:6px 12px; font-size:11px; color:var(--fg-dim); background:#20252c; border-bottom:1px solid var(--line); line-height:1.4} .ai-chat{flex:1; overflow-y:auto; padding:10px; display:flex; flex-direction:column; gap:8px; min-height:120px; max-height:280px} .ai-msg{max-width:88%; padding:7px 10px; border-radius:8px; font-size:12.5px; line-height:1.55; white-space:pre-wrap; word-break:break-word} .ai-msg.user{align-self:flex-end; background:#27456e; color:#eaf2ff} @@ -139,9 +169,11 @@ input[type=number]{width:90px} .chip:hover{color:var(--fg); border-color:var(--accent)} .ai-input-row{display:flex; gap:8px; padding:10px; border-top:1px solid var(--line)} .ai-input-row textarea{flex:1; resize:none; user-select:text} +#ai-input{min-height:48px; line-height:1.5} #ai-send{width:64px; background:linear-gradient(180deg,#3d8bff,#2f6fd6); border-color:#2459a8; color:#fff; font-weight:600} #ai-send:disabled{filter:grayscale(.7)} #ai-send .spin{display:inline-block; animation:spin 1s linear infinite} +.ai-input-hint{padding:0 12px 8px; color:var(--fg-dim); font-size:10px; text-align:right; border-top:0} @keyframes spin{to{transform:rotate(360deg)}} /* ---------- misc ---------- */ @@ -164,6 +196,21 @@ input[type=number]{width:90px} .splash-bar-fill{height:100%; width:0%; background:var(--accent); transition:width .3s} .hidden{display:none !important} +@media (max-width:900px){ + #app-chrome{gap:8px} + .chrome-actions{display:none} + #tree-panel{width:190px} + #props-panel{width:250px} + #ai-panel{width:min(390px,calc(100% - 20px)); right:10px; bottom:10px} +} +@media (max-width:680px){ + #app-chrome{padding:0 7px} + .chrome-context{font-size:10px} + .chrome-doc{max-width:150px} + #tree-panel{width:170px} + #props-panel{display:none} + #ai-panel{width:calc(100% - 16px); right:8px; bottom:8px; max-height:76%} +} ::-webkit-scrollbar{width:9px; height:9px} ::-webkit-scrollbar-thumb{background:var(--line2); border-radius:5px} ::-webkit-scrollbar-track{background:transparent} diff --git a/index.html b/index.html index 7f44a10..fa7b210 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ MiniSW · AI 参数化建模 - + + diff --git a/js/agent.js b/js/agent.js index d40a323..f24bed1 100644 --- a/js/agent.js +++ b/js/agent.js @@ -1,21 +1,29 @@ // agent.js —— AI 智能体建模:自然语言 → 建模操作序列 → 执行 -// 两条路径: -// 1) 内置规则解析器(离线可用,中文优先) -// 2) LLM 智能体(OpenAI 兼容接口,可配置,生成同样的 op 序列) +// 统一路径:本地精确闸门优先;任何模糊、缺参或复合请求交给一次紧凑的 LLM 规划, +// 最终统一通过同一套 DSL 校验、预检和执行。 // // 宏展开层(expandOps):高级指令(倒角/圆角/多边形/抽壳/镜像/阵列/凸台) // 在 validateOps / executeOps 之前被展开为已有原语操作序列, // 无需修改 geometry.js / doc.js 的接口契约。 +import { createAssembly, addComponent, addMate, checkAllInterferences, setExplode } from './features/assembly.js?v=20260810-assembly2'; +import { createDrawing, layoutThreeViews, addView, addDimension, addSectionView } from './features/drawing.js'; + +let agentConstraintSeq = 1000000; +const nextIdForAgent = () => agentConstraintSeq++; export const DSL_SPEC = ` 你是参数化三维建模智能体。根据用户的中文/英文需求,生成一段"建模操作序列"。 -只输出一个 JSON 数组,不要输出任何其他文字、注释或 Markdown。 +你可以在模型内部充分分析、反复校验和规划;这些分析不属于最终输出,不要写入 message content。 +最终只输出一个 {"ops":[...]} JSON 对象,不要输出任何其他文字、注释或 Markdown。 -坐标系:X 向右、Y 向上、Z 朝外。单位:毫米 mm。 -可用草图平面:front(前视,法向+Y)、top(上视,法向+Z)、right(右视,法向+X)。 +坐标系:X 向右、Y 向上、Z 朝外。单位:毫米 mm。模型参数必须保持真实工程尺寸;不要为了适配屏幕擅自按比例缩小或放大,视口会在生成后自动取景。 +可用草图平面:front(前视,法向+Z)、top(上视,法向+Y)、right(右视,法向+X)。 2D 坐标用 [u,v],v 为草图平面内"上"方向。 支持的 op(按顺序执行): +{"op":"query_document","answer":"根据当前模型状态回答用户的问题"} —— 查询当前文档;answer 必须是给用户的最终中文回答 +{"op":"delete_feature","featureId":3} —— 删除稳定 ID 对应的特征;用户只说“删除”时优先使用 selectedFeatureId,否则使用 lastFeatureId +{"op":"update_feature","featureId":3,"patch":{"depth":20}} —— 修改特征参数;patch 只包含需要改变的字段 {"op":"sketch","plane":"front|top|right","offset":0,"name":"草图1"} —— 开始一个新草图;后续 2D 实体属于它,直到下一个 sketch。offset 为沿法向偏移。 {"op":"rect","cx":0,"cy":0,"w":100,"h":80} —— 中心在(cx,cy)、宽 w、高 h 的矩形 @@ -23,10 +31,26 @@ export const DSL_SPEC = ` {"op":"circle","cx":0,"cy":0,"r":10} —— 圆 {"op":"arc","cx":0,"cy":0,"r":10,"a0":0,"a1":1.5708} —— 圆弧(a0/a1 为起止弧度) {"op":"centerline","p1":[u,v],"p2":[u,v]} —— 中心线(构造线,不参与实体,可作旋转轴) -{"op":"extrude","depth":20,"flip":false,"symmetric":false,"name":"拉伸1"} - —— 把当前草图拉伸成凸台(拉伸方向为平面法向;flip 反向) -{"op":"cut","depth":5,"throughAll":true,"name":"切除1"} - —— 用当前草图拉伸切除(throughAll=true 时完全贯穿) +{"op":"ellipse","cx":0,"cy":0,"rx":30,"ry":15,"rotation":0} —— 椭圆 +{"op":"slot","p1":[-20,0],"p2":[20,0],"width":10} —— 长圆槽 +{"op":"spline","points":[[u,v],...],"closed":false} —— 样条折线近似 +{"op":"constraint","type":"horizontal|vertical|coincident|parallel|perpendicular|equal|distance|length|radius|diameter|angle|fixed","refs":[],"value":0} —— 草图约束 +{"op":"set_dimension","name":"d1","expression":"120"} —— 命名尺寸 +{"op":"set_parameter","name":"pitch","value":2} —— 参数表变量 +{"op":"sketch_pattern_linear","entityIds":[1],"count":4,"dx":20,"dy":0} —— 草图线性阵列 +{"op":"sketch_pattern_circular","entityIds":[1],"count":6,"cx":0,"cy":0} —— 草图圆周阵列 +{"op":"assembly_create","name":"装配体"} —— 新建装配体 +{"op":"assembly_add_component","id":"base","name":"底座","source":"current"} —— 向装配体插入当前零件 +{"op":"mate","type":"coincident|concentric|distance|parallel|perpendicular|angle","refA":{},"refB":{},"value":0} —— 添加装配配合 +{"op":"interference_check"} —— 检查组件干涉 +{"op":"explode","progress":1,"vectors":{}} —— 设置爆炸视图 +{"op":"drawing_generate","format":"A4","orientation":"landscape","scale":1} —— 生成三视图工程图 +{"op":"drawing_dimension","value":0,"text":"尺寸"} —— 添加工程图尺寸 +{"op":"drawing_section","name":"A-A 剖视图"} —— 添加剖视图 +{"op":"extrude","mode":"blind|throughAll|symmetric|toNext|toFace|toBody","depth":20,"targetFaceRef":null,"targetBodyRef":null,"offset":0,"flip":false,"name":"拉伸1"} + —— 把当前草图拉伸成凸台;blind/symmetric 需要 depth,toFace 需要稳定面/基准面引用,toBody 当前目标写 current +{"op":"cut","mode":"throughAll","depth":5,"targetFaceRef":null,"targetBodyRef":null,"offset":0,"flip":false,"name":"切除1"} + —— 用当前草图拉伸切除;终止模式与 extrude 相同 {"op":"revolve","revolveDegrees":360,"flip":false,"axis":null,"name":"旋转1"} —— 把当前草图绕旋转轴旋转成凸台。旋转轴:默认草图 u=0 处的竖直轴; 若草图含 centerline 则自动以它为轴。 @@ -34,6 +58,18 @@ export const DSL_SPEC = ` —— 直接生成圆柱原语(轴线沿 Z,无需草图)。tx/ty/tz 为世界系偏移。 {"op":"cylcut","r":20,"h":80,"through":true,"tx":0,"ty":0,"tz":0,"name":"圆柱孔1"} —— 圆柱切除(through=true 贯穿;否则深度 h) +{"op":"sweep","path":[[0,0,0],[100,0,0]],"radius":5,"segments":32,"name":"扫描1"} + —— 用圆形截面沿三维路径扫描。path 至少两个不同的世界坐标点;radius 为截面半径 +{"op":"loft","profiles":[[[x,y,z],...],[[x,y,z],...]],"resampleCount":32,"name":"放样1"} + —— 两个或多个平行封闭截面之间放样;profiles 中每项是一个三维闭合点环 +{"op":"rib","thickness":3,"extent":30,"side":"both|left|right","draftDegrees":0,"flip":false,"name":"筋1"} + —— 把当前连续开放草图路径扩展为薄壁筋并与已有主体合并 +{"op":"chamfer_edge","edgeRef":{"type":"edge-query","point":[0,-40,0],"direction":[1,0,0]},"distance":5,"name":"实体倒角1"} + —— 对已有凸实体最接近定位点且方向匹配的一条直棱做真实倒角 +{"op":"fillet_edge","edgeRef":{"type":"edge-query","point":[0,-40,0],"direction":[1,0,0]},"radius":5,"segments":8,"name":"实体圆角1"} + —— 对已有凸实体的一条直棱做恒定半径、多段圆弧过渡的真实圆角 +{"op":"reference_plane","kind":"offset","source":"front","distance":10,"name":"偏移基准面1"} + —— 创建参考基准面。kind 可为 offset;source 为已有基准面 ID 高级 op(会被自动展开为上述原语序列): {"op":"chamfer_rect","cx":0,"cy":0,"w":100,"h":80,"chamfer":5} @@ -50,6 +86,8 @@ export const DSL_SPEC = ` —— 线性阵列上一个特征(count 含原始实例,dx/dy 为步距) {"op":"pattern_circular","count":6,"cx":0,"cy":0} —— 圆周阵列上一个特征(count 含原始实例,绕(cx,cy)均布) +{"op":"spur_gear","module":2,"teeth":20,"width":20,"pressureAngle":20,"boreDiameter":10,"name":"直齿轮"} + —— 参数化渐开线直齿轮;只提供齿轮参数,齿形坐标、单齿和圆周阵列由本地几何编译器生成 规则: - 特征按顺序执行:凸台=并集,切除=差集。 @@ -57,6 +95,8 @@ export const DSL_SPEC = ` - 圆孔半径 = 直径/2。 - 钻孔位置要相对底板边界留出合理边距(通常 10~20mm)。 - 空心圆柱:外层 cylinder 后再加一个稍大的 cylcut。 +- 直齿轮必须优先使用单个 spur_gear 高级 op,禁止手算渐开线坐标或逐个输出轮齿。 +- 用户只给出对象名或模糊用途(如“椅子”“桌子”“支架”)时,必须根据常见机械设计默认值继续建模,不要追问尺寸;把默认值写入特征 name 或参数,只有删除/修改目标不存在等无法推断的请求才追问。 - 简单模型应控制在 10 个 op 以内。 - 镜像/阵列操作前必须先有一个特征(凸台/切除/圆柱等)。 `; @@ -90,6 +130,199 @@ export function parseNaturalLanguage(text) { const hasCircularPattern = /圆周阵列|环形阵列|圆周均布|环形均布/.test(t); const hasMirror = /镜像|对称复制/.test(t); const hasBoss = /凸台|凸起|加.*圆柱|顶.*圆柱|加.*凸/.test(t); + const hasSweep = /扫描|扫掠/.test(t); + const hasLoft = /放样/.test(t); + const hasRib = /筋板|加强筋|肋板|\b筋\b/.test(t); + const hasEllipse = /椭圆|椭圆形/.test(t); + const hasSlot = /长圆槽|腰形槽|长槽/.test(t); + const hasEdgeChamfer = /实体.{0,8}倒角|棱边.{0,8}倒角|边缘.{0,8}倒角/.test(t); + const hasEdgeFillet = /实体.{0,8}圆角|棱边.{0,8}圆角|边缘.{0,8}圆角|R\s*\d+.{0,8}圆角/i.test(t); + + // 复合请求按依赖顺序拆解:零件 → 装配 → 工程图。 + // 不能让“工程图”分支提前吞掉前面的装配和建模意图。 + const wantsDrawing = /工程图|三视图|出图|图纸/.test(t); + const wantsAssembly = /装配体|装配/.test(t); + if (wantsDrawing && wantsAssembly) { + const drawingStart = t.search(/(?:最后|然后)[^。]*(?:工程图|三视图|图纸|出图)/); + const modelText = (drawingStart >= 0 ? t.slice(0, drawingStart) : t.replace(/[^。]*(?:工程图|三视图|图纸|出图)[^。]*$/g, '')).trim(); + const modelOps = modelText ? parseNaturalLanguage(modelText) : []; + const format = /A3/i.test(t) ? 'A3' : 'A4'; + const orientation = /竖|纵向|portrait/i.test(t) ? 'portrait' : 'landscape'; + const drawingOps = [{ op: 'drawing_generate', format, orientation, scale: 1, name: '自动工程图' }]; + if (/标注|尺寸|公差/.test(t)) drawingOps.push({ op: 'drawing_dimension', value: dims ? parseFloat(dims[1]) : 0, text: dims ? `${dims[1]} mm` : '尺寸' }); + if (/剖视|剖面/.test(t)) drawingOps.push({ op: 'drawing_section', name: 'A-A 剖视图' }); + return [...modelOps, ...drawingOps]; + } + + if (wantsDrawing) { + const format = /A3/i.test(t) ? 'A3' : 'A4'; + const orientation = /竖|纵向|portrait/i.test(t) ? 'portrait' : 'landscape'; + return [{ op: 'drawing_generate', format, orientation, scale: 1, name: '自动工程图' }]; + } + if (/装配体|装配/.test(t) && /新建|创建|建立|装配/.test(t)) { + // 装配描述常常同时包含“先做一个底座”。不能在这里提前返回, + // 否则 assembly_add_component 会在实体尚未重建时触发“没有当前零件实体”。 + // 先生成可用的底座,再由执行器在插入组件前确保 bodyMesh 已更新。 + const seed = []; + if (dims || hasPlate) { + const baseW = dims ? parseFloat(dims[1]) : 120; + const baseH = dims ? parseFloat(dims[2]) : 80; + const baseD = dims ? parseFloat(dims[3]) : 12; + seed.push( + { op: 'sketch', plane: 'front', name: '装配底座草图' }, + { op: 'rect', cx: 0, cy: 0, w: baseW, h: baseH }, + { op: 'extrude', mode: 'blind', depth: baseD, name: '装配底座' } + ); + const diaValues = [...t.matchAll(/直径\s*[::]?\s*(\d+(?:\.\d+)?)/g)].map(m => parseFloat(m[1])); + if (/凸台|凸起/.test(t)) { + const bossDia = diaValues[0] || 50; + const bossHeight = height || 20; + seed.push({ op: 'cylinder', r: bossDia / 2, h: bossHeight, tz: baseD - 1 + bossHeight / 2, name: '装配中心凸台' }); + } + if (/中心.*孔|孔.*中心|通孔/.test(t) && diaValues.length > 1) { + seed.push( + { op: 'sketch', plane: 'front', name: '装配中心孔草图' }, + { op: 'circle', cx: 0, cy: 0, r: diaValues[diaValues.length - 1] / 2 }, + { op: 'cut', mode: 'throughAll', name: '装配中心通孔' } + ); + } + } + const assemblyOps = [ + { op: 'assembly_create', name: '课程装配体' }, + { op: 'assembly_add_component', id: 'current', name: '当前零件', source: 'current', fixed: true } + ]; + if (/干涉|碰撞/.test(t)) assemblyOps.push({ op: 'interference_check' }); + if (/爆炸视图|爆炸/.test(t)) assemblyOps.push({ op: 'explode', progress: 1 }); + return [...seed, ...assemblyOps]; + } + + // ---- M10 轴承座:底板、两种实体边处理、凸台、通孔、参考面和双筋 ---- + if (/轴承座|轴承支座|bearing\s*housing/i.test(t)) { + const baseW = dims ? parseFloat(dims[1]) : 120; + const baseH = dims ? parseFloat(dims[2]) : 80; + const baseD = dims ? parseFloat(dims[3]) : 12; + const bossDia = matchD(t, '(?:主体凸台|凸台|轴承台).{0,12}?直径\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 60; + const bossHeight = matchD(t, '(?:主体凸台|凸台|轴承台).{0,12}?(?:高度|高)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 32; + const boreDia = matchD(t, '(?:中心通孔|通孔|轴承孔).{0,12}?直径\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 30; + const ribThickness = matchD(t, '(?:筋|筋板|加强筋).{0,10}?(?:厚度|厚)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 4; + const edgeSize = matchD(t, '(?:R|r|圆角|倒角)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 4; + const bossCenterZ = baseD - 1 + bossHeight / 2; + return [ + { op: 'sketch', plane: 'front', offset: 0, name: '轴承座底板草图' }, + { op: 'rect', cx: 0, cy: 0, w: baseW, h: baseH }, + { op: 'extrude', mode: 'blind', depth: baseD, name: '底板' }, + { op: 'chamfer_edge', edgeRef: { type: 'edge-query', point: [0, -baseH / 2, 0], direction: [1, 0, 0] }, distance: edgeSize, name: '底板实体倒角' }, + { op: 'fillet_edge', edgeRef: { type: 'edge-query', point: [0, baseH / 2, 0], direction: [1, 0, 0] }, radius: edgeSize, segments: 8, name: '底板实体圆角' }, + { op: 'cylinder', r: bossDia / 2, h: bossHeight, tz: bossCenterZ, name: '主体凸台' }, + { op: 'reference_plane', kind: 'offset', source: 'front', distance: baseD / 2, name: '筋定位基准面' }, + { op: 'sketch', plane: 'front', planeRefId: 'last', name: '左筋路径' }, + { op: 'line', p1: [-baseW * 0.38, 0], p2: [-bossDia * 0.38, 0] }, + { op: 'rib', thickness: ribThickness, extent: bossHeight * 0.85, side: 'both', name: '左加强筋' }, + { op: 'sketch', plane: 'front', planeRefId: 'last', name: '右筋路径' }, + { op: 'line', p1: [bossDia * 0.38, 0], p2: [baseW * 0.38, 0] }, + { op: 'rib', thickness: ribThickness, extent: bossHeight * 0.85, side: 'both', name: '右加强筋' }, + { op: 'sketch', plane: 'front', offset: 0, name: '中心通孔草图' }, + { op: 'circle', cx: 0, cy: 0, r: boreDia / 2 }, + { op: 'cut', mode: 'toNext', name: '中心通孔' }, + ]; + } + + // ---- 凸块单棱实体倒角/圆角(先建主体,再用稳定几何查询选边) ---- + if (hasEdgeChamfer || hasEdgeFillet) { + const w = dims ? parseFloat(dims[1]) : 40; + const h = dims ? parseFloat(dims[2]) : 40; + const d = dims ? parseFloat(dims[3]) : 40; + const value = hasEdgeFillet + ? (matchD(t, '(?:R|r|半径)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 5) + : (matchD(t, '倒角\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 5); + return [ + { op: 'sketch', plane: 'front', offset: 0, name: '实体边处理草图' }, + { op: 'rect', cx: 0, cy: 0, w, h }, + { op: 'extrude', mode: 'blind', depth: d, name: '基础凸块' }, + hasEdgeFillet + ? { op: 'fillet_edge', edgeRef: { type: 'edge-query', point: [0, -h / 2, 0], direction: [1, 0, 0] }, radius: value, segments: 8, name: '实体圆角' } + : { op: 'chamfer_edge', edgeRef: { type: 'edge-query', point: [0, -h / 2, 0], direction: [1, 0, 0] }, distance: value, name: '实体倒角' }, + ]; + } + + // ---- 矩形到圆形的基础放样 ---- + if (hasLoft) { + const rectMatch = t.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)\s*的?\s*矩形/); + const rw = rectMatch ? parseFloat(rectMatch[1]) : 40; + const rh = rectMatch ? parseFloat(rectMatch[2]) : 30; + const circleDia = matchD(t, '(?:直径|圆径)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 20; + const loftHeight = matchD(t, '(?:高度|高|间距|距离)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 40; + const rectProfile = [[-rw / 2, -rh / 2, 0], [rw / 2, -rh / 2, 0], [rw / 2, rh / 2, 0], [-rw / 2, rh / 2, 0]]; + const circleProfile = []; + for (let i = 0; i < 24; i++) { + const a = 2 * Math.PI * i / 24; + circleProfile.push([circleDia / 2 * Math.cos(a), circleDia / 2 * Math.sin(a), loftHeight]); + } + return [{ op: 'loft', profiles: [rectProfile, circleProfile], resampleCount: 32, name: '矩形到圆形放样' }]; + } + + // ---- 单独创建偏移基准面 ---- + if (/基准面/.test(t) && /偏移|距离/.test(t) && !hasPlate && !hasHole) { + const distance = matchD(t, '(?:偏移|距离)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 10; + const source = /上视/.test(t) ? 'top' : (/右视/.test(t) ? 'right' : 'front'); + return [{ op: 'reference_plane', kind: 'offset', source, distance, name: '偏移基准面' }]; + } + + // ---- 圆形截面扫描:直线路径 / 四分之一圆弧路径 ---- + if (hasSweep) { + const sectionDia = + matchD(t, '圆形截面.{0,12}?直径\\s*(\\d+(?:\\.\\d+)?)') ?? + matchD(t, '直径\\s*(\\d+(?:\\.\\d+)?).{0,12}?圆形截面') ?? + matchD(t, '管径\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? diameter; + const sectionRadius = + matchD(t, '圆形截面.{0,12}?半径\\s*(\\d+(?:\\.\\d+)?)') ?? + matchD(t, '截面半径\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? + (sectionDia ? sectionDia / 2 : 5); + if (/四分之一圆弧|90度圆弧|90°圆弧/.test(t)) { + const pathRadius = + matchD(t, '路径半径\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? + matchD(t, '半径\\s*(\\d+(?:\\.\\d+)?).{0,8}?(?:四分之一|90)') ?? 50; + const path = []; + const samples = 16; + for (let i = 0; i <= samples; i++) { + const a = Math.PI * 0.5 * i / samples; + path.push([pathRadius * Math.cos(a), pathRadius * Math.sin(a), 0]); + } + return [{ op: 'sweep', path, radius: sectionRadius, segments: 32, name: '圆弧扫描' }]; + } + const length = matchD(t, '长度?\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 100; + return [{ op: 'sweep', path: [[0, 0, 0], [length, 0, 0]], radius: sectionRadius, segments: 32, name: '直线扫描' }]; + } + + // ---- 椭圆 / 长圆槽基础参数化草图 ---- + if (hasEllipse || hasSlot) { + const shapeDims = t.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/); + const a = shapeDims ? parseFloat(shapeDims[1]) : 60; + const b = shapeDims ? parseFloat(shapeDims[2]) : 30; + const h2 = height || (dims ? parseFloat(dims[3]) : null); + ops.push({ op: 'sketch', plane: 'front', name: hasEllipse ? '椭圆草图' : '长圆槽草图' }); + if (hasEllipse) ops.push({ op: 'ellipse', cx: 0, cy: 0, rx: a / 2, ry: b / 2 }); + else ops.push({ op: 'slot', p1: [-Math.max(1, a / 2 - b / 2), 0], p2: [Math.max(1, a / 2 - b / 2), 0], width: b }); + if (h2 > 0) ops.push({ op: 'extrude', depth: h2, name: hasEllipse ? '椭圆实体' : '长圆槽实体' }); + return ops; + } + + // ---- 底板上的直筋/加强筋 ---- + if (hasRib) { + const baseW = dims ? parseFloat(dims[1]) : (matchD(t, '底板.{0,10}?(?:宽|长)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 100); + const baseH = dims ? parseFloat(dims[2]) : 60; + const baseDepth = dims ? parseFloat(dims[3]) : (matchD(t, '(?:底板厚|厚度|厚)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 10); + const ribThickness = matchD(t, '(?:筋板|加强筋|筋).{0,8}?(?:厚度|厚)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? matchD(t, '厚\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)\\s*(?:mm|毫米)?\\s*(?:筋|筋板)') ?? 3; + const ribHeight = matchD(t, '(?:筋高|筋板高|加强筋高)\\s*[::]?\\s*(\\d+(?:\\.\\d+)?)') ?? 30; + return [ + { op: 'sketch', plane: 'front', offset: 0, name: '底板草图' }, + { op: 'rect', cx: 0, cy: 0, w: baseW, h: baseH }, + { op: 'extrude', mode: 'blind', depth: baseDepth, name: '底板' }, + { op: 'sketch', plane: 'front', offset: baseDepth / 2, name: '筋路径' }, + { op: 'line', p1: [-baseW * 0.35, 0], p2: [baseW * 0.35, 0] }, + { op: 'rib', thickness: ribThickness, extent: ribHeight, side: 'both', name: '加强筋' }, + ]; + } // 提取所有"直径N"出现的位置,用于区分盘体直径和孔直径 const diaMatches = [...t.matchAll(/直径\s*(\d+(?:\.\d+)?)/g)].map(m => parseFloat(m[1])); @@ -305,6 +538,371 @@ function matchD(text, re) { return m ? parseFloat(m[1]) : null; } +// ============ 1.5 自然语言意图与文档 CRUD ============ +const FEATURE_TYPE_ALIASES = { + rib: ['筋', '加强筋', '肋板', '筋板'], + chamfer: ['倒角'], + fillet: ['圆角', '过渡圆'], + extrude: ['拉伸', '底板', '主体'], + cut: ['切除', '孔', '通孔', '槽'], + cylinder: ['凸台', '圆柱', '圆盘'], + cylcut: ['孔', '通孔'], + revolve: ['旋转'], + sweep: ['扫描', '扫掠'], + loft: ['放样'], +}; + +function compactText(s) { return normalize(String(s || '')).replace(/\s+/g, ''); } + +export function classifyIntent(text, doc = null) { + const t = compactText(text); + if (!t) return 'unknown'; + if (/^(查询|查看|显示|列出|告诉我|汇报|检查|你能看到|看到了什么|描述模型|观察|有哪些|多少|什么|当前状态|模型状态)|(?:查询|查看|显示|描述|观察).*(?:尺寸|参数|特征|草图|状态|信息|模型|实体)/.test(t)) return 'query'; + if (/^(删除|移除|去掉|取消|撤销).*(?:特征|草图|筋|倒角|圆角|孔|凸台)?$/.test(t) || /(?:删除|移除|去掉|取消).*(?:特征|草图|筋|倒角|圆角|孔|凸台)/.test(t)) return 'delete'; + if (/^(修改|更改|调整|把|将|设置|设定|重命名|改名)/.test(t) || /(?:改为|改成|设置为|调整为|重命名为|改名为)/.test(t)) return 'update'; + if (/^(做|生成|创建|新建)\s*(?:一个|一件|一只|个)?/.test(t)) return 'create'; + if (/^(在|向|给|为).*(?:添加|增加|加上|开一个|打一个|建立)/.test(t) || /(?:添加|增加|加上|开一个|打一个).*(?:孔|筋|凸台|倒角|圆角|特征)/.test(t)) return 'add'; + return doc?.features?.length ? 'add' : 'create'; +} + +function featureTypeLabel(type) { + return ({ extrude: '拉伸', cut: '切除', revolve: '旋转', cylinder: '圆柱/凸台', cylcut: '圆柱切除', rib: '加强筋', chamfer: '实体倒角', fillet: '实体圆角', sweep: '扫描', loft: '放样' })[type] || type; +} + +export function resolveFeatureTarget(doc, text) { + if (!doc?.features?.length) return null; + const t = compactText(text); + if (/最后|最近|上一个|末尾/.test(t)) return doc.features.at(-1); + const ranked = doc.features.map((f, index) => { + const name = compactText(f.name); + let score = name && t.includes(name) ? 100 + index : 0; + for (const alias of FEATURE_TYPE_ALIASES[f.type] || []) if (t.includes(compactText(alias))) score = Math.max(score, 20 + index); + return { f, score }; + }).filter(x => x.score > 0).sort((a, b) => b.score - a.score); + return ranked[0]?.f || null; +} + +function numberAfter(text, labels) { + const label = labels.join('|'); + const m = text.match(new RegExp(`(?:${label})\\s*(?:为|改为|改成|设为|设置为|调整为|到|[::=])?\\s*(-?\\d+(?:\\.\\d+)?)`)); + return m ? Number(m[1]) : null; +} + +function inferFeaturePatch(feature, text) { + const t = compactText(text); + const patch = {}; + if (feature.type === 'rib') { + const thickness = numberAfter(t, ['厚度', '厚', '筋宽']); + const extent = numberAfter(t, ['筋高', '高度', '延伸范围', '延伸']); + if (thickness != null) patch.thickness = thickness; + if (extent != null) patch.extent = extent; + if (/左侧/.test(t)) patch.side = 'left'; + if (/右侧/.test(t)) patch.side = 'right'; + } else if (feature.type === 'chamfer') { + const distance = numberAfter(t, ['倒角距离', '倒角', '距离']); + if (distance != null) patch.distance = distance; + } else if (feature.type === 'fillet') { + const radius = numberAfter(t, ['圆角半径', '半径', '圆角', 'R', 'r']); + if (radius != null) patch.radius = radius; + } else if (feature.type === 'extrude' || feature.type === 'cut') { + const depth = numberAfter(t, ['深度', '高度', '距离']); + if (depth != null) patch.depth = depth; + } else if (feature.type === 'cylinder' || feature.type === 'cylcut') { + const diameter = numberAfter(t, ['直径']); + const radius = numberAfter(t, ['半径']); + const height = numberAfter(t, ['高度', '高', '深度']); + if (radius != null) patch.r = radius; + else if (diameter != null) patch.r = diameter / 2; + if (height != null) patch.h = height; + } + const nameMatch = t.match(/(?:重命名为|改名为|命名为)([^,。,;;]+)/); + if (nameMatch && nameMatch[1]) patch.__name = nameMatch[1].trim(); + return patch; +} + +function buildAddOps(text, doc) { + const t = compactText(text); + const bb = doc?.bbox; + if (bb && /筋|肋板/.test(t)) { + const w = bb.max[0] - bb.min[0], h = bb.max[1] - bb.min[1]; + const thickness = numberAfter(t, ['厚度', '厚', '筋宽']) ?? 3; + const extent = numberAfter(t, ['筋高', '高度', '延伸范围', '延伸']) ?? Math.max(10, bb.max[2] - bb.min[2]); + const alongY = /宽度方向|纵向|沿Y|沿y/.test(t); + const path = alongY ? [[0, -h * 0.35], [0, h * 0.35]] : [[-w * 0.35, 0], [w * 0.35, 0]]; + return [ + { op: 'sketch', plane: 'front', offset: (bb.min[2] + bb.max[2]) / 2, name: '新增筋路径' }, + { op: 'line', p1: path[0], p2: path[1] }, + { op: 'rib', thickness, extent, side: /左侧/.test(t) ? 'left' : (/右侧/.test(t) ? 'right' : 'both'), name: '新增加强筋' }, + ]; + } + if (bb && /(?:孔|通孔|打孔|钻孔)/.test(t)) { + const diameter = numberAfter(t, ['直径']) ?? ((numberAfter(t, ['半径']) ?? 5) * 2); + return [ + { op: 'sketch', plane: 'front', offset: (bb.min[2] + bb.max[2]) / 2, name: '新增孔草图' }, + { op: 'circle', cx: 0, cy: 0, r: diameter / 2 }, + { op: 'cut', mode: 'throughAll', throughAll: true, name: '新增通孔' }, + ]; + } + if (bb && /凸台|圆柱/.test(t)) { + const diameter = numberAfter(t, ['直径']) ?? 30; + const height = numberAfter(t, ['高度', '高']) ?? 10; + return [{ op: 'cylinder', r: diameter / 2, h: height, tz: bb.max[2] + height / 2, name: '新增凸台' }]; + } + return parseNaturalLanguage(text); +} + +export function documentQuery(doc, text) { + if (!doc) return '当前没有打开的模型文档。'; + const t = compactText(text); + if (/你能看到|看到了什么|描述模型|观察|检查一下|几何状态|实体状态/.test(t)) return describeInspection(inspectDocument(doc)); + const bb = doc.bbox; + const dimensions = bb ? `${(bb.max[0] - bb.min[0]).toFixed(2)}×${(bb.max[1] - bb.min[1]).toFixed(2)}×${(bb.max[2] - bb.min[2]).toFixed(2)} mm` : '暂无实体'; + const target = resolveFeatureTarget(doc, text); + if (target && /参数|厚度|高度|半径|直径|尺寸|详细/.test(t)) { + return `${target.name}(${featureTypeLabel(target.type)})参数:${JSON.stringify(target.params)}`; + } + if (/尺寸|大小|外形/.test(t)) return `当前实体尺寸:${dimensions}。草图 ${doc.sketches.length} 张,特征 ${doc.features.length} 个。`; + if (/参考|基准面/.test(t)) return `参考几何:${doc.references.map(r => r.name).join('、') || '无'}。`; + return `当前模型:${dimensions};草图 ${doc.sketches.length} 张;特征 ${doc.features.length} 个:${doc.features.map(f => `${f.name}(${featureTypeLabel(f.type)}${f.error ? ',错误' : ''})`).join('、') || '无'}。`; +} + +function meshStats(mesh) { + if (!mesh || !Array.isArray(mesh.verts) || !Array.isArray(mesh.tris)) return null; + const b = { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] }; + for (let i = 0; i + 2 < mesh.verts.length; i += 3) { + for (let k = 0; k < 3; k++) { + const v = Number(mesh.verts[i + k]); + if (!Number.isFinite(v)) continue; + b.min[k] = Math.min(b.min[k], v); + b.max[k] = Math.max(b.max[k], v); + } + } + const valid = b.min.every(Number.isFinite) && b.max.every(Number.isFinite); + return { vertices: mesh.verts.length / 3, triangles: mesh.tris.length / 3, bbox: valid ? b : null }; +} + +export function inspectDocument(doc) { + if (!doc) return { hasBody: false, status: 'NoDocument', errors: ['没有打开文档'] }; + const body = doc.bodyManifold; + let status = body ? 'Unknown' : 'Empty'; + let volume = null, surfaceArea = null; + try { status = body?.status?.() || status; } catch { status = 'Invalid'; } + try { volume = body?.volume?.(); } catch { volume = null; } + try { surfaceArea = body?.surfaceArea?.(); } catch { surfaceArea = null; } + const bbox = doc.bbox ? { min: doc.bbox.min.slice(), max: doc.bbox.max.slice() } : null; + const dimensions = bbox ? bbox.max.map((v, i) => v - bbox.min[i]) : null; + const features = (doc.features || []).map(f => ({ + id: f.id, + name: f.name, + type: f.type, + params: f.params, + error: f.error || null, + delta: meshStats(doc.deltaMeshes?.[f.id]), + })); + const errors = features.filter(f => f.error).map(f => `${f.name}: ${f.error}`); + if (status !== 'NoError' && status !== 'Empty') errors.push(`实体状态:${status}`); + if (body && !(Number(volume) > 0)) errors.push('实体体积不是正数'); + return { + hasBody: !!body, + status, + bbox, + dimensions, + volume: Number.isFinite(Number(volume)) ? Number(volume) : null, + surfaceArea: Number.isFinite(Number(surfaceArea)) ? Number(surfaceArea) : null, + mesh: meshStats(doc.bodyMesh), + sketches: (doc.sketches || []).map(s => ({ id: s.id, name: s.name, plane: typeof s.plane === 'string' ? s.plane : 'reference', entities: s.entities?.length || 0 })), + features, + errors, + }; +} + +export function describeInspection(snapshot) { + if (!snapshot?.hasBody) return `当前没有可观察的实体(状态:${snapshot?.status || 'Empty'})。${snapshot?.errors?.join(';') || ''}`; + const d = snapshot.dimensions?.map(v => Number(v).toFixed(2)).join('×') || '未知'; + const volume = snapshot.volume == null ? '未知' : Number(snapshot.volume).toFixed(2); + const mesh = snapshot.mesh ? `,网格 ${snapshot.mesh.vertices} 个顶点/${snapshot.mesh.triangles} 个三角形` : ''; + const featureText = snapshot.features.map(f => `${f.name}(${featureTypeLabel(f.type)}${f.error ? ',失败' : ''})`).join('、') || '无'; + const errorText = snapshot.errors.length ? ` 注意:${snapshot.errors.join(';')}` : ''; + return `我观察到一个状态为 ${snapshot.status} 的实体,尺寸约 ${d} mm,体积 ${volume} mm³${mesh};特征为:${featureText}。${errorText}`; +} + +/** + * 将本地几何观察结果整理成“视觉模块反馈”。这里不传图片, + * 而是把模型位置、尺寸、网格、特征和实体完整性反馈给机械制图专家。 + */ +export function buildVisualFeedback(snapshot) { + const source = snapshot || {}; + return { + observer: 'MiniSW local geometry observer', + hasBody: !!source.hasBody, + status: source.status || 'Empty', + placement: { + bbox: source.bbox || null, + dimensions: source.dimensions || null, + }, + massProperties: { + volume: source.volume ?? null, + surfaceArea: source.surfaceArea ?? null, + }, + mesh: source.mesh || null, + sketches: source.sketches || [], + featureTree: (source.features || []).map(feature => ({ + id: feature.id, + name: feature.name, + type: feature.type, + params: feature.params || {}, + error: feature.error || null, + delta: feature.delta || null, + })), + integrity: { + errors: source.errors || [], + readableSummary: describeInspection(source), + }, + }; +} + +/** + * 建模完成后的单次专家复核。复核模型不需要多模态输入, + * 只依据本地视觉/几何观察器生成的结构化反馈判断是否符合意图。 + */ +export async function reviewGeneratedModel(userText, cfg, context = {}, ops = [], snapshot = {}) { + const started = Date.now(); + context.onStage?.('模型已生成,正在让机械制图专家进行视觉复核…'); + const feedback = buildVisualFeedback(snapshot); + const messages = [ + { role: 'system', content: await readProjectSourceContext() }, + ...(context.modelState ? [{ role: 'user', content: context.modelState }] : []), + { role: 'user', content: JSON.stringify({ originalRequest: userText, generatedOps: ops, visualFeedback: feedback }) }, + ]; + let content; + try { + content = await callLLMAPI(cfg, messages); + context.onTrace?.({ stage: 'vision_review', ok: true, durationMs: Date.now() - started, request: messages, response: content }); + const result = extractJSONObject(content); + return { + pass: result.pass !== false, + severity: result.severity || (result.pass === false ? 'error' : 'info'), + summary: String(result.summary || (result.pass === false ? '专家发现需要修正的问题' : '专家复核通过')), + findings: Array.isArray(result.findings) ? result.findings.map(String) : [], + correction: String(result.correction || ''), + feedback, + calls: 1, + durationMs: Date.now() - started, + trace: [{ stage: 'vision_review', ok: true, durationMs: Date.now() - started }], + }; + } catch (error) { + // 视觉复核失败不覆盖已经成功生成的实体,只将失败显示给用户。 + context.onTrace?.({ stage: 'vision_review', ok: false, durationMs: Date.now() - started, request: messages, error: error.message || String(error) }); + return { + pass: true, + severity: 'warning', + summary: '视觉复核未能解析专家结果,模型本体仍已生成', + findings: [error.message || String(error)], + correction: '', + feedback, + reviewError: true, + calls: 1, + durationMs: Date.now() - started, + trace: [{ stage: 'vision_review', ok: false, durationMs: Date.now() - started, error: error.message || String(error) }], + }; + } +} + +export function interpretAgentRequest(text, doc = null) { + const intent = classifyIntent(text, doc); + if (intent === 'query') return { kind: 'query', intent, reply: documentQuery(doc, text) }; + if (intent === 'delete') { + const target = resolveFeatureTarget(doc, text); + return target ? { kind: 'delete', intent, featureId: target.id, featureName: target.name } : { kind: 'error', intent, message: '没有找到要删除的特征。请指定名称,例如“删除左加强筋”或“删除最后一个特征”。' }; + } + if (intent === 'update') { + const target = resolveFeatureTarget(doc, text); + if (!target) return { kind: 'error', intent, message: '没有找到要修改的特征。请指定特征名称或类型。' }; + const patch = inferFeaturePatch(target, text); + if (!Object.keys(patch).length) return { kind: 'error', intent, message: `已找到“${target.name}”,但没有识别到新的参数。可以说“把${target.name}厚度改为6mm”。` }; + return { kind: 'update', intent, featureId: target.id, featureName: target.name, patch }; + } + const ops = intent === 'add' && doc?.features?.length ? buildAddOps(text, doc) : parseNaturalLanguage(text); + if (ops) return { kind: 'ops', intent, ops, replaceExisting: intent === 'create' }; + return { kind: 'unknown', intent, message: '我暂时无法确定这是新建、追加、修改、删除还是查询。请补充对象和参数。' }; +} + +function hasConcreteEngineeringInput(text) { + const value = compactText(text); + if (!/(?:\d+(?:\.\d+)?|直径|半径|宽度|长度|厚度|高度|深度|距离|模数|齿数|齿宽|压力角|孔径|外径|内径)/.test(value)) return false; + // 这些词意味着对象或约束仍然需要模型判断,本地解析不能擅自替用户定方案。 + if (/随便|大概|差不多|适当|合理|默认|常见|类似|像|之类|先做个|看着办|椅子|桌子|凳子|支架|托架|齿轮|一个圆柱|一个底座/.test(value)) return false; + return true; +} + +/** + * 统一 Agent 的本地闸门:只有“动作、对象、参数、依赖”都能由本地规则完整确认时才返回, + * 其余一律返回 null,由统一 LLM Agent 在一次紧凑调用中理解和补全。 + */ +export function decideLocalRequest(text, doc = null) { + const request = interpretAgentRequest(text, doc); + if (request.kind === 'query') return request; + if (request.kind === 'delete') { + const value = compactText(text); + return /最后|最近|上一个|末尾|选中|当前特征|(?:删除|移除|去掉).+(?:特征|草图|筋|倒角|圆角|孔|凸台)/.test(value) ? request : null; + } + if (request.kind === 'update') return request; + if (request.kind !== 'ops' || !request.ops?.length) return null; + if (!hasConcreteEngineeringInput(text)) return null; + const schemaError = validateOps(request.ops); + return schemaError ? null : request; +} + +export function applyAgentMutation(doc, request) { + if (!doc || !request) throw new Error('缺少文档或智能体操作请求'); + if (request.kind === 'delete') { + const f = doc.featureById(request.featureId); + if (!f) throw new Error('目标特征已不存在'); + const sketchId = f.sketchId; + doc.deleteFeature(f.id); + if (sketchId) doc.deleteSketch(sketchId); + return `已删除特征“${f.name}”。`; + } + if (request.kind === 'update') { + const f = doc.featureById(request.featureId); + if (!f) throw new Error('目标特征已不存在'); + const patch = { ...request.patch }; + if (patch.__name) { f.name = patch.__name; delete patch.__name; } + doc.updateFeature(f.id, patch); + return `已修改特征“${f.name}”:${JSON.stringify(patch)}。`; + } + throw new Error('不是可变更的智能体请求'); +} + +export function repairOps(ops, failures = []) { + const failedNames = new Set(failures.map(f => compactText(f.name)).filter(Boolean)); + const failedTypes = new Set(failures.map(f => f.type).filter(Boolean)); + const removed = []; + const next = ops.filter(op => { + const name = compactText(op.name); + const hit = (name && failedNames.has(name)) || (!name && failedTypes.has(op.op)); + if (hit) removed.push(op.name || op.op); + return !hit; + }); + return { ops: next, removed }; +} + +// 复合请求的安全执行顺序:先产生实体,再引用实体做装配,最后生成工程图。 +// 保持同一阶段内的原始顺序,避免 LLM 输出顺序偶发颠倒导致前置对象缺失。 +function orderOpsForDependencies(ops) { + const rank = op => { + const name = String(op?.op || ''); + if (name === 'assembly_create') return 20; + if (name.startsWith('assembly_') || ['mate', 'interference_check', 'explode'].includes(name)) return 30; + if (name === 'drawing_generate') return 40; + if (name.startsWith('drawing_')) return 50; + return 10; + }; + return ops.map((op, index) => ({ op, index, rank: rank(op) })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map(item => item.op); +} + // ============ 2. 宏展开引擎 ============ /** * 将高级 op 展开为原语 op 序列。 @@ -335,6 +933,12 @@ export function expandOps(ops) { case 'pattern_circular': out.push(...expandPatternCircular(op, out)); break; + case 'spur_gear': + out.push(...expandSpurGear(op)); + break; + case 'bevel_gear': + out.push(...expandBevelGear(op)); + break; default: out.push(op); } @@ -342,6 +946,98 @@ export function expandOps(ops) { return out; } +// ---- 参数化渐开线直齿轮 → 齿根圆柱 + 单齿轮廓 + 圆周阵列 + 中心孔 ---- +function expandSpurGear(op) { + const moduleValue = Number(op.module); + const teeth = Math.round(Number(op.teeth)); + const width = Number(op.width); + const pressureAngle = Number(op.pressureAngle ?? 20); + const pitchRadius = moduleValue * teeth / 2; + const addendumRadius = pitchRadius + moduleValue; + const rootRadius = Math.max(moduleValue, pitchRadius - 1.25 * moduleValue); + const baseRadius = pitchRadius * Math.cos(pressureAngle * Math.PI / 180); + const boreDiameter = Number(op.boreDiameter ?? Math.max(2 * moduleValue, pitchRadius * 0.5)); + const boreRadius = Math.max(0, boreDiameter / 2); + const alpha = pressureAngle * Math.PI / 180; + const involute = angle => Math.tan(angle) - angle; + const halfPitchToothAngle = Math.PI / (2 * teeth); + const flankStartRadius = Math.max(baseRadius, rootRadius); + const flankAngleAt = radius => { + const ratio = Math.min(1, baseRadius / Math.max(baseRadius, radius)); + const localAlpha = Math.acos(ratio); + return Math.max(0.002, halfPitchToothAngle + involute(alpha) - involute(localAlpha)); + }; + const polar = (radius, angle) => [radius * Math.cos(angle), radius * Math.sin(angle)]; + const flankSamples = 7; + const tipSamples = 5; + const rootJoinRadius = Math.min(rootRadius - moduleValue * 0.05, Math.max(boreRadius + moduleValue * 0.2, rootRadius - moduleValue * 0.25)); + const startAngle = flankAngleAt(flankStartRadius); + const points = [polar(rootJoinRadius, -startAngle)]; + if (flankStartRadius > rootJoinRadius + 1e-6) points.push(polar(flankStartRadius, -startAngle)); + for (let i = 1; i <= flankSamples; i++) { + const radius = flankStartRadius + (addendumRadius - flankStartRadius) * i / flankSamples; + points.push(polar(radius, -flankAngleAt(radius))); + } + const tipAngle = flankAngleAt(addendumRadius); + for (let i = 1; i <= tipSamples; i++) { + points.push(polar(addendumRadius, -tipAngle + 2 * tipAngle * i / tipSamples)); + } + for (let i = flankSamples - 1; i >= 0; i--) { + const radius = flankStartRadius + (addendumRadius - flankStartRadius) * i / flankSamples; + points.push(polar(radius, flankAngleAt(radius))); + } + points.push(polar(rootJoinRadius, startAngle)); + + const name = op.name || '直齿轮'; + const result = [ + { op: 'cylinder', r: rootRadius, h: width, tz: width / 2, name: `${name}-齿根圆齿坯` }, + { op: 'sketch', plane: 'front', name: `${name}-渐开线单齿草图` }, + ...ptsToLines(points, `${name}-渐开线单齿草图`), + { op: 'extrude', depth: width, name: `${name}-渐开线单齿` }, + { op: 'pattern_circular', count: teeth, cx: 0, cy: 0 }, + ]; + if (boreRadius > 0) result.push({ op: 'cylcut', r: boreRadius, h: width + 2, through: true, tz: width / 2, name: `${name}-中心孔` }); + return expandOps(result); +} + +// ---- 鍥寸齿轮 → 两个渐缩齿形截面放样 + 中心孔 ---- +// 这是 CAD 预览级近似:保留锥形外观和齿数/模数关系,避免把锥齿轮错误降级为直齿轮。 +function expandBevelGear(op) { + const moduleValue = Number(op.module); + const teeth = Math.round(Number(op.teeth)); + const width = Number(op.width); + const pressureAngle = Number(op.pressureAngle ?? 20); + const pitchRadius = moduleValue * teeth / 2; + const addendumRadius = pitchRadius + moduleValue; + const rootRadius = Math.max(moduleValue, pitchRadius - 1.25 * moduleValue); + const boreDiameter = Number(op.boreDiameter ?? Math.max(2 * moduleValue, pitchRadius * 0.5)); + const boreRadius = Math.max(0, boreDiameter / 2); + const shaftAngle = Number(op.shaftAngle ?? 90); + const pitchConeAngle = Number(op.pitchConeAngle ?? shaftAngle / 2); + const coneDistance = Number(op.outerConeDistance ?? pitchRadius / Math.max(Math.sin(pitchConeAngle * Math.PI / 180), 1e-6)); + const smallScale = Math.max(0.35, Math.min(0.92, 1 - width / Math.max(coneDistance, width + 1))); + const step = 2 * Math.PI / teeth; + const offsets = [-0.5, -0.34, -0.2, 0.2, 0.34]; + const radii = [rootRadius, rootRadius, addendumRadius, addendumRadius, rootRadius]; + const outline = []; + for (let tooth = 0; tooth < teeth; tooth++) { + for (let i = 0; i < offsets.length; i++) { + const angle = tooth * step + offsets[i] * step; + outline.push([radii[i] * Math.cos(angle), radii[i] * Math.sin(angle)]); + } + } + const profile = (z, scale) => outline.map(([x, y]) => [x * scale, y * scale, z]); + const name = op.name || '\u9525\u9f7f\u8f6e'; + const result = [{ + op: 'loft', + profiles: [profile(0, 1), profile(width, smallScale)], + resampleCount: Math.max(32, teeth * offsets.length), + name: `${name}-\u9525\u9f7f\u8f6e\u9f7f\u4f53`, + }]; + if (boreRadius > 0) result.push({ op: 'cylcut', r: boreRadius, h: width + 2, through: true, tz: width / 2, name: `${name}-\u4e2d\u5fc3\u5b54` }); + return result; +} + // ---- 倒角矩形 → 8 段直线(八边形) ---- function expandChamferRect(op) { const cx = op.cx || 0, cy = op.cy || 0; @@ -573,91 +1269,740 @@ function duplicateGroupRotated(group, cx, cy, ang, suffix) { } // ============ 3. LLM 智能体 ============ +const LLM_DEFAULTS = { baseURL: '', apiKey: '', model: '' }; +const PROJECT_SOURCE_FILES = [ + './agent.js', './doc.js', './geometry.js', './sketch.js', './math.js', + './features/assembly.js', './features/drawing.js', './features/termination.js', +]; +let projectSourceContextPromise = null; + +function removeLegacyPromptText(source) { + return String(source).replace(/export const DSL_SPEC\s*=\s*`[\s\S]*?`;/, ''); +} + +async function readProjectSourceContext() { + if (typeof window === 'undefined' || typeof fetch !== 'function') { + return `MiniSW 本地项目文件:${PROJECT_SOURCE_FILES.join(', ')}。运行时由浏览器直接读取这些文件。`; + } + if (!projectSourceContextPromise) { + projectSourceContextPromise = Promise.all(PROJECT_SOURCE_FILES.map(async file => { + try { + const response = await fetch(new URL(file, import.meta.url)); + if (!response.ok) return `[${file}] 读取失败:HTTP ${response.status}`; + return `\n===== ${file} =====\n${removeLegacyPromptText(await response.text())}`; + } catch (error) { + return `[${file}] 读取失败:${error.message || String(error)}`; + } + })).then(parts => `MiniSW 本地项目文件源码:\n${parts.join('\n')}`); + } + return projectSourceContextPromise; +} + +const AGENT_PROJECT_FILES = [ + 'index.html', 'js/main.js', 'js/agent.js', 'js/doc.js', 'js/geometry.js', 'js/sketch.js', 'js/math.js', + 'js/features/assembly.js', 'js/features/drawing.js', 'js/features/termination.js', +]; + +export const AGENT_TOOL_DEFINITIONS = [ + { + type: 'function', + function: { + name: 'list_project_files', + description: '列出 MiniSW 当前项目中可读取的源码文件。', + parameters: { type: 'object', properties: {}, additionalProperties: false }, + }, + }, + { + type: 'function', + function: { + name: 'read_project_file', + description: '读取 MiniSW 项目目录内的源码文件。需要了解能力、参数或执行方式时自行调用。', + parameters: { + type: 'object', + properties: { path: { type: 'string', description: '相对于 MiniSW 项目目录的文件路径,例如 js/agent.js 或 js/doc.js' } }, + required: ['path'], + additionalProperties: false, + }, + }, + }, + { + type: 'function', + function: { + name: 'inspect_model', + description: '读取当前文档、特征树、尺寸、引用、装配和工程图状态。', + parameters: { type: 'object', properties: {}, additionalProperties: false }, + }, + }, + { + type: 'function', + function: { + name: 'execute_model_ops', + description: '在当前 MiniSW 文档中执行建模操作序列;返回真实执行结果或几何错误。', + parameters: { + type: 'object', + properties: { ops: { type: 'array', items: { type: 'object' }, description: '按依赖顺序排列的 MiniSW 操作对象数组' } }, + required: ['ops'], + additionalProperties: false, + }, + }, + }, + { + type: 'function', + function: { + name: 'reset_model', + description: '清空当前 MiniSW 文档并开始一个新的零件文档。', + parameters: { type: 'object', properties: {}, additionalProperties: false }, + }, + }, +]; + +export async function readProjectFile(path) { + const raw = String(path || '').replaceAll('\\', '/').replace(/^\.\//, ''); + if (!raw || raw.includes('..') || raw.startsWith('/') || /^[a-z]+:/i.test(raw)) throw new Error('只能读取 MiniSW 项目目录内的相对路径'); + if (typeof fetch !== 'function') throw new Error('当前运行环境没有本地文件读取接口'); + const response = await fetch(new URL(`../${raw}`, import.meta.url)); + if (!response.ok) throw new Error(`读取 ${raw} 失败:HTTP ${response.status}`); + return { path: raw, content: await response.text() }; +} + +export function listProjectFiles() { + return { root: 'MiniSW', files: AGENT_PROJECT_FILES.slice() }; +} + +function firstFinite(...values) { + for (const value of values) { + if (value === null || value === undefined || value === '') continue; + const number = Number(value); + if (Number.isFinite(number)) return number; + } + return null; +} + +function buildDefaultDesignGuidance(userText, normalizedIntent) { + const summary = `${userText}\n${JSON.stringify(normalizedIntent || {})}`; + if (/椅子|座椅|办公椅|餐椅|chair/i.test(summary)) return '这是一个模糊的椅子创建请求。请采用行业常用默认参数继续建模:座面500×500×450mm,座面厚度40mm,靠背宽500mm、高450mm、厚30mm,四条40×40mm椅腿;这些是 mm 制的真实工程尺寸,不要为了适配视口擅自缩放,也不要向用户追问。模型显示大小由软件视口自动适配。'; + if (/凳子|stool/i.test(summary)) return '这是一个模糊的凳子创建请求。请采用默认参数继续建模:座面400×400×450mm,厚度40mm,四条30×30mm椅腿;不要向用户追问。'; + if (/桌子|工作台|table|desk/i.test(summary)) return '这是一个模糊的桌子创建请求。请采用默认参数继续建模:桌面1200×600×30mm,高750mm,四条40×40mm桌腿;不要向用户追问。'; + if (Array.isArray(normalizedIntent?.ambiguities) && normalizedIntent.ambiguities.length) return '需求存在未指定项,但这是创建请求。请根据常见机械设计习惯补充合理默认值并继续建模,把假设写入名称或参数;不要因为缺少非关键尺寸而追问。'; + return ''; +} + +function compileKnownMechanicalIntent(userText, normalizedIntent, expertBrief) { + if (!normalizedIntent || !['create', 'add'].includes(String(normalizedIntent.intent || 'create'))) return null; + const summary = `${userText}\n${JSON.stringify(normalizedIntent)}\n${JSON.stringify(expertBrief)}`; + if (!/锥齿轮|直齿轮|直齿圆柱齿轮|bevel\s*gear|spur\s*gear/i.test(summary)) return null; + const dimensions = { ...(expertBrief?.dimensions || {}), ...(normalizedIntent?.dimensions || {}) }; + if (/\u9525\u9f7f\u8f6e|bevel\s*gear/i.test(summary)) { + const moduleValue = firstFinite(dimensions.module, dimensions.modulus, 2); + const teeth = Math.round(firstFinite(dimensions.teeth, dimensions.toothCount, dimensions.z, 20)); + const width = firstFinite(dimensions.width, dimensions.faceWidth, dimensions.thickness, 8); + const pressureAngle = firstFinite(dimensions.pressureAngle, 20); + const pitchDiameter = moduleValue * teeth; + const boreDiameter = firstFinite(dimensions.boreDiameter, dimensions.holeDiameter, Math.max(2 * moduleValue, pitchDiameter * 0.25)); + return [{ + op: 'bevel_gear', module: moduleValue, teeth, width, pressureAngle, + shaftAngle: firstFinite(dimensions.shaftAngle, dimensions.shaftAngleDeg, 90), + pitchConeAngle: firstFinite(dimensions.pitchConeAngle, dimensions.pitchConeAngleDeg, 45), + boreDiameter, name: normalizedIntent?.part?.name || '\u9525\u9f7f\u8f6e', + }]; + } + const moduleValue = firstFinite(dimensions.module, dimensions.modulus, 2); + const teeth = Math.round(firstFinite(dimensions.teeth, dimensions.toothCount, dimensions.z, 20)); + const width = firstFinite(dimensions.width, dimensions.faceWidth, dimensions.thickness, 20); + const pressureAngle = firstFinite(dimensions.pressureAngle, 20); + const pitchDiameter = moduleValue * teeth; + const boreDiameter = firstFinite(dimensions.boreDiameter, dimensions.holeDiameter, Math.max(2 * moduleValue, pitchDiameter * 0.25)); + return [{ op: 'spur_gear', module: moduleValue, teeth, width, pressureAngle, boreDiameter, name: normalizedIntent?.part?.name || '直齿轮' }]; +} + +// ============ 3.1 快速决策层 ============ +// 对象名很短、意图很明确时,不再把请求交给多轮 LLM:先用本地规则给出 +// 可执行的最小方案,并把默认工程假设记录到 agentMeta,保证判断果决且可追溯。 +function parseModelState(context) { + if (!context?.modelState) return null; + try { + const state = JSON.parse(context.modelState); + return state && typeof state === 'object' ? state : null; + } catch { return null; } +} + +function localStatePlan(userText, context) { + const state = parseModelState(context); + if (!state) return null; + const intent = classifyIntent(userText, { features: state.features || [] }); + const features = Array.isArray(state.features) ? state.features : []; + const selectedId = state.selectedFeatureId ?? state.lastFeatureId ?? features.at(-1)?.id; + const target = resolveFeatureTarget({ features }, userText) + || features.find(feature => String(feature.id) === String(selectedId)) + || (selectedId != null ? { id: selectedId, name: '当前特征', type: '' } : null); + let ops = null; + let answer = ''; + if (intent === 'delete' && target) { + ops = [{ op: 'delete_feature', featureId: target.id }]; + } else if (intent === 'update' && target) { + const patch = inferFeaturePatch(target, userText); + if (Object.keys(patch).length) ops = [{ op: 'update_feature', featureId: target.id, patch }]; + } else if (intent === 'query') { + const dimensions = Array.isArray(state.dimensions) ? state.dimensions.map(value => Number(value).toFixed(2)).join('×') : '未知'; + const featureText = features.map(feature => `${feature.name || '未命名特征'}(${featureTypeLabel(feature.type)})`).join('、') || '无'; + answer = `当前模型状态为 ${state.status || '未知'},尺寸约 ${dimensions} mm;特征为:${featureText}。`; + ops = [{ op: 'query_document', answer }]; + } + if (!ops) return null; + const requestType = intent === 'update' ? 'modify' : intent; + const meta = { + mode: 'local-decision', requestType, confidence: 'high', calls: 0, + trace: [{ stage: 'local_decision', ok: true, local: true, requestType }], + }; + context.onStage?.('已读取当前模型状态,直接执行确定性操作…'); + context.onTrace?.({ stage: 'local_decision', ok: true, local: true, request: userText, response: { ops } }); + return { ops, meta }; +} + +function inferSimpleObject(text) { + let value = compactText(text).replace(/[。.!!??]+$/, ''); + for (let i = 0; i < 3; i++) value = value.replace(/^(?:我想要|我需要|请给我|给我|帮我|请|来一个|来个|做|生成|创建|新建|建模)/, ''); + value = value.replace(/^(?:一个|一件|一只|一把|个)/, ''); + if (!value || /并|同时|以及|工程图|装配|添加|修改|删除|查询|出图/.test(value)) return null; + if (/锥齿轮|bevel\s*gear/i.test(value)) return { key: 'bevel_gear', label: '锥齿轮' }; + if (/直齿轮|直齿圆柱齿轮|spur\s*gear/i.test(value)) return { key: 'spur_gear', label: '直齿轮' }; + if (/椅子|座椅|办公椅|餐椅|chair/i.test(value)) return { key: 'chair', label: '椅子' }; + if (/凳子|stool/i.test(value)) return { key: 'stool', label: '凳子' }; + if (/桌子|工作台|table|desk/i.test(value)) return { key: 'table', label: '桌子' }; + if (/支架|托架|bracket|support/i.test(value)) return { key: 'bracket', label: '支架' }; + return null; +} + +function simpleGearIntent(text, object) { + const value = compactText(text); + return { + intent: 'create', + part: { name: object.label }, + dimensions: { + module: numberAfter(value, ['模数', 'module']) ?? undefined, + teeth: numberAfter(value, ['齿数', '齿', 'teeth']) ?? undefined, + width: numberAfter(value, ['齿宽', '宽度', '厚度', 'width']) ?? undefined, + boreDiameter: numberAfter(value, ['孔径', '孔直径', '内孔', 'bore']) ?? undefined, + pressureAngle: numberAfter(value, ['压力角', 'pressureAngle']) ?? undefined, + }, + assumptions: ['未指定参数采用 MiniSW 默认机械设计值'], + ambiguities: [], + }; +} + +function buildSimpleObjectOps(object, text) { + if (object.key === 'bevel_gear' || object.key === 'spur_gear') { + return compileKnownMechanicalIntent(text, simpleGearIntent(text, object), null); + } + if (object.key === 'chair') { + return [{ op: 'cylinder', r: 250, h: 40, tz: 450, name: '默认座面' }]; + } + if (object.key === 'stool') { + return [ + { op: 'cylinder', r: 200, h: 40, tz: 450, name: '默认凳面' }, + { op: 'cylinder', r: 18, h: 430, tz: 215, name: '默认凳柱' }, + ]; + } + if (object.key === 'table') { + return [ + { op: 'sketch', plane: 'top', name: '默认桌面草图' }, + { op: 'rect', cx: 0, cy: 0, w: 1200, h: 600 }, + { op: 'extrude', depth: 30, name: '默认桌面' }, + { op: 'cylinder', r: 20, h: 750, tx: -520, ty: -220, tz: -360, name: '默认桌腿1' }, + { op: 'cylinder', r: 20, h: 750, tx: 520, ty: -220, tz: -360, name: '默认桌腿2' }, + { op: 'cylinder', r: 20, h: 750, tx: -520, ty: 220, tz: -360, name: '默认桌腿3' }, + { op: 'cylinder', r: 20, h: 750, tx: 520, ty: 220, tz: -360, name: '默认桌腿4' }, + ]; + } + return [ + { op: 'sketch', plane: 'front', name: '默认支架底座草图' }, + { op: 'rect', cx: 0, cy: 0, w: 120, h: 80 }, + { op: 'extrude', depth: 12, name: '默认支架底座' }, + ]; +} + +/** + * 返回高确定性的本地计划;返回 null 表示仍需 LLM 处理。 + * 这里刻意只接管短对象请求,避免把“复杂复合请求”误判为单一默认模型。 + */ +export function decideAgentPlan(userText, context = {}) { + const request = decideLocalRequest(userText, context.document || null); + if (!request) return null; + return { + ops: request.ops, + meta: { mode: 'local-exact', requestType: request.intent, confidence: 'high', calls: 0, trace: [{ stage: 'local_exact', ok: true, local: true }] }, + }; +} + export function getLLMSettings() { try { const raw = localStorage.getItem('minisw_llm'); - return raw ? JSON.parse(raw) : { baseURL: '', apiKey: '', model: '' }; - } catch { return { baseURL: '', apiKey: '', model: '' }; } + const stored = raw ? JSON.parse(raw) : {}; + delete stored.speed; + return { ...LLM_DEFAULTS, ...stored }; + } catch { return { ...LLM_DEFAULTS }; } } -export function setLLMSettings(s) { localStorage.setItem('minisw_llm', JSON.stringify(s)); } +export function setLLMSettings(s) { + const next = { ...LLM_DEFAULTS, ...s }; + delete next.speed; + localStorage.setItem('minisw_llm', JSON.stringify(next)); +} + +/** + * 聊天型 Agent:LLM 自主读取源码、观察模型、调用建模工具,并在工具结果返回后继续工作。 + * 这里不预先拼装 DSL 提示,也不替 LLM 做意图分流。 + */ +export async function runAgentChat(userText, cfg, context = {}) { + const trace = []; + const messages = Array.isArray(context.history) ? context.history.slice() : []; + messages.push({ role: 'user', content: userText }); + const toolHandlers = { + list_project_files: () => listProjectFiles(), + read_project_file: args => readProjectFile(args?.path), + ...(context.toolHandlers || {}), + }; + let turn = 0; + while (true) { + const started = Date.now(); + const response = await callLLMAPI(cfg, messages, { + tools: AGENT_TOOL_DEFINITIONS, + returnResponse: true, + stream: false, + }); + trace.push({ stage: turn ? 'tool_followup' : 'agent_chat', ok: true, durationMs: Date.now() - started }); + context.onTrace?.({ stage: turn ? 'tool_followup' : 'agent_chat', ok: true, durationMs: Date.now() - started, request: messages, response }); + const toolCalls = Array.isArray(response.toolCalls) ? response.toolCalls : []; + if (!toolCalls.length) { + return { + reply: String(response.text || ''), + agentMeta: { mode: 'tool-chat', calls: trace.length, trace }, + }; + } + messages.push({ role: 'assistant', content: response.text || null, tool_calls: toolCalls }); + for (const call of toolCalls) { + const name = call?.function?.name || call?.name; + let args = {}; + try { args = typeof call?.function?.arguments === 'string' ? JSON.parse(call.function.arguments || '{}') : (call?.arguments || {}); } + catch (error) { args = { _parseError: error.message || String(error) }; } + let result; + try { + if (args._parseError) throw new Error('工具参数不是有效 JSON:' + args._parseError); + if (typeof toolHandlers[name] !== 'function') throw new Error(`不存在工具:${name}`); + result = await toolHandlers[name](args); + } catch (error) { + result = { ok: false, error: error.message || String(error) }; + } + const toolResult = typeof result === 'string' ? result : JSON.stringify(result); + context.onTrace?.({ stage: 'tool_call', ok: !result?.error, tool: name, arguments: args, result }); + messages.push({ role: 'tool', tool_call_id: call.id || `tool-${turn}`, name, content: toolResult }); + } + turn++; + } +} /** - * 调用 LLM 生成操作序列。 + * 兼容旧调用方:直接要求 LLM 返回可解析的操作序列。 * @param {string} userText 用户输入 * @param {object} cfg LLM 配置 {baseURL, apiKey, model} * @param {object} context 可选上下文 {modelState, history} */ export async function runLLM(userText, cfg, context = {}) { - const messages = [{ role: 'system', content: DSL_SPEC }]; - if (context.modelState) { - messages.push({ role: 'system', content: '当前模型状态:' + context.modelState + '。在已有模型基础上继续建模,不要重建已有特征。' }); - } - if (context.history && context.history.length) { - messages.push(...context.history.slice(-6)); - } + const trace = []; + const traceSnapshot = value => { + try { return structuredClone(value); } + catch { try { return JSON.parse(JSON.stringify(value)); } catch { return value; } } + }; + const invoke = async (stage, messages, options = {}) => { + const started = Date.now(); + const streamId = `${stage}-${started}-${trace.length}`; + try { + const content = await callLLMAPI(cfg, messages, { + ...options, + onDelta: delta => { + options.onDelta?.(delta); + context.onStream?.({ ...delta, stage, streamId }); + }, + }); + const durationMs = Date.now() - started; + trace.push({ stage, ok: true, durationMs }); + context.onTrace?.({ stage, ok: true, durationMs, request: traceSnapshot(messages), response: content }); + return content; + } catch (error) { + const durationMs = Date.now() - started; + trace.push({ stage, ok: false, durationMs, error: error.message || String(error) }); + context.onTrace?.({ stage, ok: false, durationMs, request: traceSnapshot(messages), error: error.message || String(error) }); + throw error; + } + }; + context.onStage?.('统一 Agent 正在理解并规划…'); + const projectFiles = await readProjectSourceContext(); + const messages = [{ role: 'system', content: projectFiles }]; + if (context.modelState) messages.push({ role: 'user', content: context.modelState }); + if (context.history?.length) messages.push(...context.history); messages.push({ role: 'user', content: userText }); - const maxRetries = 1; let lastError = null; - for (let attempt = 0; attempt <= maxRetries; attempt++) { + for (let attempt = 0; ; attempt++) { let content; try { - content = await callLLMAPI(cfg, messages); + content = await invoke(attempt === 0 ? 'unified_planning' : 'unified_planning_repair', messages); } catch (e) { - throw new Error('LLM 请求失败:' + (e.message || e) + '。请检查网络连接和 API 配置。'); + lastError = e.message || String(e); + context.onStage?.('统一 Agent 正在修正输出…'); + messages.push({ role: 'user', content: lastError }); + continue; } try { const ops = extractOps(content); const verr = validateOps(ops); if (verr) throw new Error('校验失败:' + verr); - return ops; + const semanticError = validatePlanCoverage(userText, ops, context); + if (semanticError) throw new Error('意图覆盖失败:' + semanticError); + return attachAgentMeta(ops, { mode: 'unified', requestType: inferAgentRequestType(userText), calls: trace.length, trace }); } catch (e) { lastError = e.message; - if (attempt === maxRetries) throw new Error('LLM 输出校验失败(已重试 1 次):' + lastError + '。可尝试用更标准的描述,或使用内置解析器。'); - messages.push({ role: 'assistant', content }); - messages.push({ role: 'user', content: `上一次输出有误:${lastError}。请修正并重新输出,只输出一个 JSON 数组。` }); + context.onStage?.('统一 Agent 正在纠正计划…'); + messages.push({ role: 'user', content: lastError }); } } } -async function callLLMAPI(cfg, messages) { +function inferAgentRequestType(text) { + const value = compactText(text); + if (/^(查询|查看|显示|列出|告诉我|汇报|你能看到|描述模型|有哪些|多少|当前状态|模型状态)/.test(value)) return 'query'; + if (/^(删除|移除|去掉|取消)/.test(value)) return 'delete'; + if (/^(修改|更改|调整|把|将|设置|设定|重命名|改名)/.test(value)) return 'modify'; + if (/装配体|装配/.test(value)) return 'assembly'; + if (/工程图|三视图|出图|图纸/.test(value)) return 'drawing'; + if (/^(在|向|给|为).*(添加|增加|加上|开一个|打一个)/.test(value)) return 'add'; + return 'create'; +} + +function attachAgentMeta(ops, meta) { + try { Object.defineProperty(ops, 'agentMeta', { value: meta, configurable: true, enumerable: false }); } + catch { ops.agentMeta = meta; } + return ops; +} + +function locallyRepairPlan(userText, failedOps, executionError) { + const error = String(executionError || ''); + if (/指定的旋转轴实体不存在/.test(error)) { + const next = JSON.parse(JSON.stringify(failedOps || [])); + let changed = false; + for (const op of next) { + if (op.op !== 'revolve' || typeof op.axis !== 'string') continue; + if (/^(?:y|vertical|default)$/i.test(op.axis)) { delete op.axis; changed = true; } + } + if (changed) return next; + } + return null; +} + +/** 软件执行失败后,把真实错误、失败计划和当前模型状态交回 LLM 修正。 */ +export async function repairLLMPlan(userText, cfg, context = {}, failedOps = [], executionError = '') { + const localRepair = locallyRepairPlan(userText, failedOps, executionError); + if (localRepair) { + const trace = { stage: 'local_plan_repair', ok: true, local: true, durationMs: 0 }; + context.onTrace?.({ ...trace, request: { userText, executionError, failedOps }, response: { ops: localRepair } }); + return attachAgentMeta(localRepair, { mode: 'local-repair', requestType: inferAgentRequestType(userText), calls: 0, trace: [trace] }); + } + context.onStage?.('软件执行失败,正在回传错误纠正…'); + const messages = [ + { role: 'system', content: await readProjectSourceContext() }, + ]; + if (context.modelState) messages.push({ role: 'user', content: context.modelState }); + messages.push({ role: 'user', content: JSON.stringify({ + originalRequest: userText, + executionError: String(executionError), + failedOps, + }) }); + let content; + const started = Date.now(); + try { + content = await callLLMAPI(cfg, messages); + context.onTrace?.({ stage: 'execution_repair', ok: true, durationMs: Date.now() - started, request: messages, response: content }); + } catch (error) { + context.onTrace?.({ stage: 'execution_repair', ok: false, durationMs: Date.now() - started, request: messages, error: error.message || String(error) }); + throw new Error('LLM 纠错请求失败:' + (error.message || error)); + } + const ops = extractOps(content); + const verr = validateOps(ops); + if (verr) throw new Error('LLM 纠错结果仍不合法:' + verr); + const semanticError = validatePlanCoverage(userText, ops, context); + if (semanticError) throw new Error('LLM 纠错结果仍未覆盖需求:' + semanticError); + return attachAgentMeta(ops, { mode: 'unified-repair', requestType: inferAgentRequestType(userText), calls: 1, trace: [{ stage: 'execution_repair', ok: true, durationMs: Date.now() - started }] }); +} + +function extractJSONObject(content) { + let source = String(content || '').replace(/^\uFEFF/, '').replace(/```(?:json)?/gi, '').replace(/```/g, '').trim(); + const start = source.indexOf('{'), end = source.lastIndexOf('}'); + if (start >= 0 && end > start) source = source.slice(start, end + 1); + let value; + try { value = JSON.parse(source); } + catch (firstError) { + const cleaned = sanitizeJsonLike(source); + try { value = JSON.parse(cleaned); } + catch { throw firstError; } + } + if (!value || Array.isArray(value) || typeof value !== 'object') throw new Error('意图结果不是 JSON 对象'); + return value; +} + +function validatePlanCoverage(userText, ops, context = {}) { + const text = normalize(userText); + const names = new Set(ops.map(op => String(op?.op || ''))); + const hasExistingBody = /"hasBody"\s*:\s*true/.test(String(context.modelState || '')); + const bodyOps = new Set(['extrude', 'revolve', 'cylinder', 'sweep', 'loft', 'shell', 'spur_gear', 'bevel_gear']); + const createsBody = ops.some(op => bodyOps.has(op.op)); + const isBevelGearRequest = /\u9525\u9f7f\u8f6e|bevel\s*gear/i.test(text); + if (isBevelGearRequest && !names.has('bevel_gear')) return '\u9525\u9f7f\u8f6e\u8bf7\u4f7f\u7528 bevel_gear\uff0c\u4e0d\u80fd\u964d\u7ea7\u4e3a spur_gear'; + if (isBevelGearRequest) return null; + const describesPart = /底座|底板|零件|实体|板|圆柱|凸台|轴|支撑|齿轮|轴承座/.test(text) && /做|创建|新建|生成|建模|加入|添加/.test(text); + const dependent = names.has('assembly_add_component') || names.has('drawing_generate'); + const primitiveOnly = [...names].every(name => ['sketch', 'rect', 'line', 'circle', 'arc', 'extrude', 'cut', 'cylinder', 'cylcut'].includes(name)); + const explicitPrimitive = /底座|底板|矩形板|圆柱|圆盘|通孔|孔|槽/.test(text); + const vagueNamedObject = !/\d/.test(text) && !explicitPrimitive && !/查询|查看|显示|修改|删除|调整/.test(text) && /做|创建|新建|生成|建模/.test(text) || (/^[\u4e00-\u9fff]{2,12}$/.test(text) && !explicitPrimitive); + if (vagueNamedObject && primitiveOnly && ops.length > 1) return '这是一个命名对象请求,当前计划只有通用几何,必须拆解并覆盖对象的主要功能组成,不能只生成普通底板或外壳'; + if (/齿轮/.test(text) && !names.has('spur_gear') && (!names.has('cylinder') || !names.has('pattern_circular') || !names.has('cylcut'))) return '直齿轮计划必须使用 spur_gear,或包含 cylinder 齿坯、单齿特征后的 pattern_circular 和中心孔 cylcut'; + if (/^(删除|移除|去掉|取消)/.test(text) && !names.has('delete_feature')) return '请求要求删除特征,但计划没有 delete_feature'; + if (/^(修改|更改|调整|把|将|设置|设定|重命名|改名)/.test(text) && !names.has('update_feature')) return '请求要求修改特征,但计划没有 update_feature'; + if (/^(查询|查看|显示|列出|告诉我|汇报|你能看到|描述模型|有哪些|多少|当前状态|模型状态)/.test(text) && !names.has('query_document')) return '请求是文档查询,但计划没有 query_document'; + if ((describesPart || dependent) && !hasExistingBody && !createsBody) return '请求需要零件实体,但计划没有任何创建实体的操作'; + if (/装配体|装配/.test(text) && !names.has('assembly_create') && !names.has('assembly_add_component')) return '请求包含装配,但计划没有装配操作'; + if (/工程图|三视图|出图|图纸/.test(text) && !names.has('drawing_generate')) return '请求包含工程图,但计划没有生成工程图'; + if (/干涉|碰撞/.test(text) && !names.has('interference_check')) return '请求要求干涉检查,但计划遗漏了该操作'; + if (/爆炸视图|爆炸/.test(text) && !names.has('explode')) return '请求要求爆炸视图,但计划遗漏了该操作'; + if (/剖视|剖面/.test(text) && !names.has('drawing_section')) return '请求要求剖视图,但计划遗漏了该操作'; + return null; +} + +async function callLLMAPI(cfg, messages, options = {}) { let base = (cfg.baseURL || 'https://api.openai.com/v1').trim().replace(/\/+$/, ''); const url = /\/chat\/completions$/.test(base) ? base : base + '/chat/completions'; - const resp = await fetch(url, { + let resp, data; + const requestBody = { + model: cfg.model, + messages, + }; + if (Array.isArray(options.tools) && options.tools.length) requestBody.tools = options.tools; + const streaming = options.stream !== false; + if (streaming) requestBody.stream = true; + const send = body => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + cfg.apiKey }, - body: JSON.stringify({ model: cfg.model, messages, temperature: 0.2 }), + body: JSON.stringify(body), }); - const data = await resp.json().catch(() => null); + try { + resp = await send(requestBody); + if (resp.ok && isStreamingResponse(resp)) { + const streamed = await readStreamingResponse(resp, options.onDelta); + data = { choices: [{ message: { content: streamed.content || null, reasoning_content: streamed.reasoning || '', tool_calls: streamed.toolCalls || [] } }] }; + if (!streamed.content && streamed.reasoning) return streamed.reasoning; + } else data = await resp.json().catch(() => null); + let apiMessage = data?.error?.message || ''; + if (!resp.ok && streaming && /stream|unsupported|unknown parameter/i.test(apiMessage)) { + delete requestBody.stream; + resp = await send(requestBody); + data = await resp.json().catch(() => null); + } + } catch (error) { + throw error; + } if (!resp.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + resp.status)); - const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; - if (!content) throw new Error('模型未返回内容'); + const content = extractLLMText(data, { allowReasoning: true }); + const toolCalls = data?.choices?.[0]?.message?.tool_calls || []; + if (options.returnResponse) return { text: content, toolCalls, raw: data }; + if (!content) { + const finish = data?.choices?.[0]?.finish_reason; + throw new Error(finish === 'length' ? '模型输出达到长度上限,但没有给出最终内容' : '模型未返回最终内容'); + } return content; } +function isStreamingResponse(resp) { + const contentType = resp?.headers?.get?.('content-type') || ''; + return Boolean(resp?.ok && resp?.body && typeof resp.body.getReader === 'function' && /text\/event-stream/i.test(contentType)); +} + +/** 读取 OpenAI Chat Completions SSE;不改变最终内容,只把增量转发给调试窗口。 */ +async function readStreamingResponse(resp, onDelta) { + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = '', content = '', reasoning = '', done = false, toolCalls = []; + const flatten = value => { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(item => flatten(item?.text ?? item?.content ?? item)).join(''); + if (value && typeof value === 'object') return flatten(value.text ?? value.content ?? value.value); + return ''; + }; + const consume = frame => { + for (const line of String(frame).split(/\r?\n/)) { + if (!line.startsWith('data:')) continue; + const raw = line.slice(5).trim(); + if (!raw || raw === '[DONE]') { done = raw === '[DONE]' || done; continue; } + let packet; + try { packet = JSON.parse(raw); } catch { continue; } + const delta = packet?.choices?.[0]?.delta || packet?.choices?.[0]?.message || packet?.choices?.[0] || {}; + const nextContent = flatten(delta.content ?? packet?.choices?.[0]?.text); + const nextReasoning = flatten(delta.reasoning_content ?? delta.reasoning); + let hasToolDelta = false; + for (const part of (Array.isArray(delta.tool_calls) ? delta.tool_calls : [])) { + const index = Number.isInteger(part.index) ? part.index : toolCalls.length; + const call = toolCalls[index] || { id: '', type: 'function', function: { name: '', arguments: '' } }; + if (part.id) call.id = part.id; + if (part.type) call.type = part.type; + if (part.function?.name) call.function.name += part.function.name; + if (part.function?.arguments) call.function.arguments += part.function.arguments; + toolCalls[index] = call; + hasToolDelta = true; + } + if (!nextContent && !nextReasoning && !hasToolDelta) continue; + content += nextContent; + reasoning += nextReasoning; + onDelta?.({ delta: nextContent, reasoningDelta: nextReasoning, accumulated: content, reasoning, toolCalls, done: false }); + } + }; + while (!done) { + const { value, done: readerDone } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !readerDone }); + let boundary; + while ((boundary = buffer.search(/\r?\n\r?\n/)) >= 0) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary).replace(/^\r?\n\r?\n/, ''); + consume(frame); + } + if (readerDone) break; + } + if (buffer.trim()) consume(buffer); + onDelta?.({ delta: '', reasoningDelta: '', accumulated: content, reasoning, done: true }); + return { content, reasoning, toolCalls }; +} + +function extractLLMText(data, options = {}) { + const choice = data?.choices?.[0]; + const message = choice?.message; + const flatten = value => { + if (typeof value === 'string') return value.trim(); + if (Array.isArray(value)) return value.map(item => flatten(item?.text ?? item?.content ?? item)).filter(Boolean).join('\n').trim(); + if (value && typeof value === 'object') return flatten(value.text ?? value.content ?? value.value); + return ''; + }; + const direct = [ + message?.content, + choice?.text, + data?.output_text, + data?.response, + data?.result, + data?.output, + ].map(flatten).find(Boolean); + if (direct) return direct; + // 部分推理模型只返回 reasoning_content;只有其中已经包含 JSON 时才作为最终答案使用。 + if (options.allowReasoning === false) return ''; + const reasoning = flatten(message?.reasoning_content ?? message?.reasoning); + if (reasoning && ((reasoning.includes('{') && reasoning.includes('}')) || (reasoning.includes('[') && reasoning.includes(']')))) return reasoning; + return ''; +} + export function extractOps(content) { - let s = content; - const i = s.indexOf('['), j = s.lastIndexOf(']'); - if (i >= 0 && j > i) s = s.slice(i, j + 1); - const ops = JSON.parse(s); - if (!Array.isArray(ops)) throw new Error('模型输出不是数组'); - return ops; + const source = String(content || '').replace(/^\uFEFF/, '').replace(/```(?:json)?/gi, '').replace(/```/g, '').trim(); + const isOpsArray = value => Array.isArray(value) && value.length > 0 && value.every(item => item && typeof item === 'object' && !Array.isArray(item) && typeof item.op === 'string'); + let firstError = null; + for (const text of [source, sanitizeJsonLike(source)]) { + try { + const root = JSON.parse(text); + if (isOpsArray(root)) return root; + if (isOpsArray(root?.ops)) return root.ops; + } catch (error) { if (!firstError) firstError = error; } + } + for (let start = source.indexOf('['); start >= 0; start = source.indexOf('[', start + 1)) { + const candidate = extractBalancedArray(source, start); + if (!candidate) continue; + for (const text of [candidate, sanitizeJsonLike(candidate)]) { + try { + const ops = JSON.parse(text); + if (isOpsArray(ops)) return ops; + } catch (error) { if (!firstError) firstError = error; } + } + } + throw firstError || new Error('模型输出中没有完整的可执行 ops 数组'); +} + +function extractBalancedArray(source, start) { + let depth = 0, quoted = false, escaped = false; + for (let i = start; i < source.length; i++) { + const ch = source[i]; + if (quoted) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') quoted = false; + continue; + } + if (ch === '"') { quoted = true; continue; } + if (ch === '[') depth++; + else if (ch === ']') { + depth--; + if (depth === 0) return source.slice(start, i + 1); + } + } + return null; +} + +// LLM 有时会把 JavaScript 对象字面量当作 JSON,统一清理常见非 JSON 值。 +function sanitizeJsonLike(source) { + return String(source) + .replace(/[“”]/g, '"').replace(/[‘’]/g, "'") + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/.*$/gm, '') + .replace(/\bundefined\b/g, 'null') + .replace(/\bNaN\b/g, 'null') + .replace(/\b-Infinity\b|\bInfinity\b/g, 'null') + .replace(/([:,\[]\s*)-(?=\s*[,}\]])/g, '$1null') + // 未加引号的工程表达式(如 s.p/(2r_p))不能执行,先标记为空值交给 DSL 校验。 + .replace(/([:,\[]\s*)(?!["'{\[\]\-]|\d|true\b|false\b|null\b)([A-Za-z_$][^,}\]]*)(?=\s*[,}\]])/g, '$1null') + .replace(/([:,\[]\s*)(?=\d[^,}\]]*[/*+\-][^,}\]]*)([^,}\]]+)(?=\s*[,}\]])/g, '$1null') + .replace(/,\s*([}\]])/g, '$1') + .replace(/([{,]\s*)([A-Za-z_$][\w$-]*)\s*:/g, '$1"$2":'); } // ============ 4. 校验 + 执行 ============ const OP_SCHEMA = { + query_document: ['answer'], + delete_feature: ['featureId'], + update_feature: ['featureId', 'patch'], sketch: ['plane'], rect: ['cx', 'cy', 'w', 'h'], line: ['p1', 'p2'], circle: ['cx', 'cy', 'r'], arc: ['cx', 'cy', 'r', 'a0', 'a1'], centerline: ['p1', 'p2'], - extrude: ['depth'], + ellipse: ['cx', 'cy', 'rx', 'ry'], + slot: ['p1', 'p2', 'width'], + spline: ['points'], + constraint: ['type', 'refs'], + set_dimension: ['name', 'expression'], + set_parameter: ['name', 'value'], + sketch_pattern_linear: ['count'], + sketch_pattern_circular: ['count'], + assembly_create: [], + assembly_add_component: ['id'], + mate: ['type', 'refA', 'refB'], + interference_check: [], + explode: [], + drawing_generate: [], + drawing_dimension: ['value'], + drawing_section: [], + extrude: [], cut: [], revolve: [], cylinder: ['r', 'h'], cylcut: ['r', 'h'], + sweep: ['path'], + loft: [], + rib: ['thickness'], + chamfer_edge: ['edgeRef', 'distance'], + fillet_edge: ['edgeRef', 'radius'], + reference_plane: ['kind'], }; const HIGH_LEVEL_OPS = { @@ -668,10 +2013,14 @@ const HIGH_LEVEL_OPS = { mirror: [], pattern_linear: ['count'], pattern_circular: ['count'], + spur_gear: ['module', 'teeth', 'width'], + bevel_gear: ['module', 'teeth', 'width'], }; export function validateOps(ops) { if (!Array.isArray(ops) || ops.length === 0) return '操作序列为空'; + const namedDimensions = new Set(ops.filter(op => op?.op === 'set_dimension' && op.name).map(op => String(op.name))); + const isPositiveDimension = value => Number(value) > 0 || (typeof value === 'string' && namedDimensions.has(value.trim())); // 预校验(含高级 op) for (const op of ops) { if (!op || typeof op !== 'object') return '包含非法 op'; @@ -681,12 +2030,61 @@ export function validateOps(ops) { if (op[k] === undefined) return `op ${op.op} 缺少参数 ${k}`; } if (op.op === 'sketch' && !['front', 'top', 'right'].includes(op.plane)) return '草图平面必须是 front/top/right'; + if ((op.op === 'rect' && (!isPositiveDimension(op.w) || !isPositiveDimension(op.h))) || ((op.op === 'circle' || op.op === 'arc') && !isPositiveDimension(op.r))) return `${op.op} 的尺寸参数必须是正数或已定义的命名尺寸`; + if ((op.op === 'cylinder' || op.op === 'cylcut') && (!(Number(op.r) > 0) || !(Number(op.h) > 0))) return `${op.op} 的半径和高度必须是正数`; + if (op.op === 'ellipse' && (!(op.rx > 0) || !(op.ry > 0))) return '椭圆长短半轴必须大于 0'; + if (op.op === 'slot' && (!(op.width > 0) || !Array.isArray(op.p1) || !Array.isArray(op.p2))) return '长圆槽参数无效'; + if (op.op === 'spline' && (!Array.isArray(op.points) || op.points.length < 2)) return '样条至少需要两个控制点'; if ((op.op === 'chamfer_rect' || op.op === 'fillet_rect') && (op.w <= 0 || op.h <= 0)) return `${op.op} 的宽高必须大于 0`; + if (op.op === 'sweep') { + if (!Array.isArray(op.path) || op.path.length < 2) return '扫描路径至少需要两个点'; + if (!op.profile && !(op.radius > 0)) return '扫描需要有效的截面或正半径'; + if (op.path.some(p => !Array.isArray(p) || p.length < 3 || p.some(v => !Number.isFinite(Number(v))))) return '扫描路径点必须是三维数值坐标'; + } + if (op.op === 'loft') { + if (!Array.isArray(op.profiles) || op.profiles.length < 2) return '放样至少需要两个截面'; + } + if (op.op === 'extrude' || op.op === 'cut') { + const mode = op.mode || op.termination || (op.throughAll ? 'throughAll' : (op.symmetric ? 'symmetric' : 'blind')); + if (!['blind', 'throughAll', 'symmetric', 'toNext', 'toFace', 'toBody'].includes(mode)) return `不支持的拉伸终止模式:${mode}`; + if ((mode === 'blind' || mode === 'symmetric') && (!(Number(op.depth) > 0))) return `${op.op} 的深度必须大于 0`; + if (mode === 'toFace' && op.targetFaceRef == null) return `${op.op} 的到面终止缺少 targetFaceRef`; + if (mode === 'toBody' && op.targetBodyRef == null) return `${op.op} 的到实体终止缺少 targetBodyRef`; + } + if (op.op === 'rib') { + if (!(Number(op.thickness) > 0) || !(Number(op.extent ?? 10) > 0)) return '筋厚度和延伸范围必须大于 0'; + if (op.side && !['both', 'left', 'right', 'single'].includes(op.side)) return '筋侧向参数无效'; + } + if (op.op === 'chamfer_edge' && !(Number(op.distance) > 0)) return '实体倒角距离必须大于 0'; + if (op.op === 'fillet_edge') { + if (!(Number(op.radius) > 0)) return '实体圆角半径必须大于 0'; + if (!Number.isInteger(Number(op.segments ?? 8)) || Number(op.segments ?? 8) < 2 || Number(op.segments ?? 8) > 48) return '实体圆角分段必须为 2 到 48 的整数'; + } + if (op.op === 'reference_plane') { + if (op.kind !== 'offset') return '内置 DSL 当前仅支持 offset 参考基准面'; + if (!Number.isFinite(Number(op.distance))) return '参考基准面偏移距离必须是数值'; + } if (op.op === 'shell') { if (op.wall !== undefined && op.wall <= 0) return '抽壳壁厚必须大于 0'; if (op.wall !== undefined && op.wall >= Math.min(op.w, op.h) / 2) return '抽壳壁厚过大,内腔会消失'; } if ((op.op === 'pattern_linear' || op.op === 'pattern_circular') && op.count < 2) return '阵列数量至少为 2'; + if (op.op === 'bevel_gear') { + if (!(Number(op.module) > 0)) return '\u9525\u9f7f\u8f6e\u6a21\u6570\u5fc5\u987b\u5927\u4e8e 0'; + if (!Number.isInteger(Number(op.teeth)) || Number(op.teeth) < 6 || Number(op.teeth) > 200) return '\u9525\u9f7f\u8f6e\u9f7f\u6570\u5fc5\u987b\u662f 6\uFF5E200 \u7684\u6574\u6570'; + if (!(Number(op.width) > 0)) return '\u9525\u9f7f\u8f6e\u9f7f\u5bbd\u5fc5\u987b\u5927\u4e8e 0'; + if (op.pressureAngle !== undefined && (!(Number(op.pressureAngle) >= 10) || !(Number(op.pressureAngle) <= 35))) return '\u9525\u9f7f\u8f6e\u538b\u529b\u89d2\u5fc5\u987b\u5728 10\u00B0\uFF5E35\u00B0 \u4e4b\u95f4'; + const pitchDiameter = Number(op.module) * Number(op.teeth); + if (op.boreDiameter !== undefined && (!(Number(op.boreDiameter) >= 0) || !(Number(op.boreDiameter) < pitchDiameter - 3.5 * Number(op.module)))) return '\u9525\u9f7f\u8f6e\u4e2d\u5fc3\u5b54\u76f4\u5f84\u8fc7\u5927'; + } + if (op.op === 'spur_gear') { + if (!(Number(op.module) > 0)) return '直齿轮模数必须大于 0'; + if (!Number.isInteger(Number(op.teeth)) || Number(op.teeth) < 6 || Number(op.teeth) > 200) return '直齿轮齿数必须是 6~200 的整数'; + if (!(Number(op.width) > 0)) return '直齿轮齿宽必须大于 0'; + if (op.pressureAngle !== undefined && (!(Number(op.pressureAngle) >= 10) || !(Number(op.pressureAngle) <= 35))) return '直齿轮压力角必须在 10°~35° 之间'; + const pitchDiameter = Number(op.module) * Number(op.teeth); + if (op.boreDiameter !== undefined && (!(Number(op.boreDiameter) >= 0) || !(Number(op.boreDiameter) < pitchDiameter - 3.5 * Number(op.module)))) return '直齿轮中心孔直径过大,齿根处必须保留足够轮缘'; + } } // 展开后结构校验 let expanded; @@ -698,9 +2096,29 @@ export function validateOps(ops) { let hasSketch = false; for (const op of expanded) { if (op.op === 'sketch') hasSketch = true; - else if (['rect', 'line', 'circle', 'arc', 'centerline'].includes(op.op) && !hasSketch) return '2D 实体 op 之前必须有 sketch'; - else if ((op.op === 'extrude' || op.op === 'cut' || op.op === 'revolve') && !hasSketch) return `${op.op} 之前必须有 sketch`; + else if (['rect', 'line', 'circle', 'arc', 'centerline', 'ellipse', 'slot', 'spline', 'constraint', 'sketch_pattern_linear', 'sketch_pattern_circular'].includes(op.op) && !hasSketch) return '2D 实体 op 之前必须有 sketch'; + else if ((op.op === 'extrude' || op.op === 'cut' || op.op === 'revolve' || op.op === 'rib') && !hasSketch) return `${op.op} 之前必须有 sketch`; + } + return null; +} + +/** 在修改文档前检查稳定引用和前置对象,避免用异常作为正常控制流。 */ +export function validateOpsAgainstDocument(doc, ops) { + if (!doc) return '没有当前文档'; + const names = new Set(ops.map(op => op.op)); + if (names.has('query_document') && ops.length > 1) return '查询操作不能与修改文档的操作混在同一个计划中'; + for (const op of ops) { + if (op.op === 'delete_feature' || op.op === 'update_feature') { + if (!doc.features.some(feature => String(feature.id) === String(op.featureId))) return `稳定特征 ID 不存在:${op.featureId}`; + } } + const createsBody = ops.some(op => ['extrude', 'revolve', 'cylinder', 'sweep', 'loft', 'shell', 'spur_gear', 'bevel_gear'].includes(op.op)); + if (!doc.bodyMesh && !createsBody && names.has('assembly_add_component')) return '装配插入缺少前置零件实体'; + if (!doc.bodyMesh && !createsBody && names.has('drawing_generate')) return '工程图生成缺少前置零件实体'; + const createsAssembly = names.has('assembly_create') || !!doc.assembly; + if (!createsAssembly && [...names].some(name => ['assembly_add_component', 'mate', 'interference_check', 'explode'].includes(name))) return '装配操作缺少装配体'; + const createsDrawing = names.has('drawing_generate') || !!doc.drawings?.length; + if (!createsDrawing && (names.has('drawing_dimension') || names.has('drawing_section'))) return '工程图标注或剖视缺少工程图'; return null; } @@ -709,13 +2127,39 @@ export function validateOps(ops) { * 高级 op 会先被 expandOps 展开为原语。 */ export function executeOps(doc, ops) { - const expanded = expandOps(ops); + const expanded = expandOps(orderOpsForDependencies(ops)); let cur = null; - const created = { sketches: [], features: [] }; + const created = { sketches: [], features: [], references: [], deleted: [], updated: [], answers: [] }; + const ensureBodyMesh = (message) => { + if (!doc.bodyMesh && doc.features?.length) doc.rebuild(); + if (!doc.bodyMesh) throw new Error(message); + return doc.bodyMesh; + }; + let lastReference = null; for (const op of expanded) { switch (op.op) { + case 'query_document': + created.answers.push(String(op.answer)); + break; + case 'delete_feature': { const feature = doc.features.find(f => String(f.id) === String(op.featureId)); + if (!feature) throw new Error(`要删除的特征不存在:${op.featureId}`); + const sketchId = feature.sketchId; + doc.deleteFeature(feature.id); + if (sketchId) doc.deleteSketch(sketchId); + created.deleted.push({ id: feature.id, name: feature.name }); + break; + } + case 'update_feature': { const feature = doc.features.find(f => String(f.id) === String(op.featureId)); + if (!feature) throw new Error(`要修改的特征不存在:${op.featureId}`); + if (!op.patch || typeof op.patch !== 'object' || Array.isArray(op.patch)) throw new Error('修改特征需要 patch 对象'); + doc.updateFeature(feature.id, op.patch); + created.updated.push({ id: feature.id, name: feature.name }); + created.features.push(feature.id); + break; + } case 'sketch': { - cur = doc.addSketch({ plane: op.plane || 'front', offset: op.offset || 0, name: op.name }); + const planeRefId = op.planeRefId === 'last' ? (lastReference && lastReference.id) : op.planeRefId; + cur = doc.addSketch({ plane: op.plane || 'front', planeRefId: planeRefId || null, offset: op.offset || 0, name: op.name }); created.sketches.push(cur.id); break; } @@ -739,13 +2183,94 @@ export function executeOps(doc, ops) { if (!cur) throw new Error('缺少草图'); doc.addEntity(cur.id, { type: 'centerline', p1: op.p1, p2: op.p2, construction: true }); break; + case 'ellipse': + if (!cur) throw new Error('缺少草图'); + doc.addEntity(cur.id, { type: 'ellipse', c: [op.cx || 0, op.cy || 0], rx: Number(op.rx), ry: Number(op.ry), rotation: Number(op.rotation || 0) }); + break; + case 'slot': + if (!cur) throw new Error('缺少草图'); + doc.addEntity(cur.id, { type: 'slot', p1: op.p1.map(Number), p2: op.p2.map(Number), width: Number(op.width) }); + break; + case 'spline': + if (!cur) throw new Error('缺少草图'); + doc.addEntity(cur.id, { type: 'spline', points: op.points.map(p => p.map(Number)), closed: !!op.closed }); + break; + case 'constraint': + if (!cur) throw new Error('缺少草图'); + cur.constraints.push({ id: nextIdForAgent(), type: op.type, refs: op.refs || [], value: op.value ?? op.target, expression: op.expression }); + break; + case 'set_dimension': + doc.setDimension(op.name, op.expression ?? op.value, op.unit || 'mm'); + break; + case 'set_parameter': + doc.setParameter(op.name, op.value); + break; + case 'assembly_create': + doc.assembly = createAssembly(op.name || '未命名装配体'); + break; + case 'assembly_add_component': + if (!doc.assembly) doc.assembly = createAssembly('未命名装配体'); + { + const bodyMesh = ensureBodyMesh('装配插入前需要当前零件实体;请先描述一个底座或零件实体'); + const bodyWidth = Math.max(Number(doc.bbox?.max?.[0]) - Number(doc.bbox?.min?.[0]), 100); + const hasTransform = op.transform && Object.keys(op.transform).length > 0; + const transform = hasTransform ? op.transform : { position: [doc.assembly.components.length * (bodyWidth + 20), 0, 0] }; + addComponent(doc.assembly, { id: op.id, name: op.name, docId: op.source === 'current' ? doc.name : op.docId, mesh: bodyMesh, transform, fixed: !!op.fixed }); + } + break; + case 'mate': + if (!doc.assembly) throw new Error('当前没有装配体'); + addMate(doc.assembly, op); + break; + case 'interference_check': + if (!doc.assembly) throw new Error('当前没有装配体'); + doc.assembly.lastInterferences = checkAllInterferences(doc.assembly); + break; + case 'explode': + if (!doc.assembly) throw new Error('当前没有装配体'); + setExplode(doc.assembly, op); + break; + case 'drawing_generate': { + const bodyMesh = ensureBodyMesh('生成工程图前需要当前零件实体;请先描述一个零件'); + const drawing = createDrawing({ name: op.name || doc.name, format: op.format || 'A4', orientation: op.orientation || 'landscape', scale: Number(op.scale || 1), units: doc.units }); + for (const view of layoutThreeViews(bodyMesh, { scale: Number(op.scale || 1) })) addView(drawing, view); + doc.drawings.push(drawing); + break; + } + case 'drawing_dimension': { + const drawing = doc.drawings.at(-1); if (!drawing) throw new Error('当前没有工程图'); + addDimension(drawing, op); break; + } + case 'drawing_section': { + const drawing = doc.drawings.at(-1); if (!drawing) throw new Error('当前没有工程图'); + addSectionView(drawing, { ...op, mesh: doc.bodyMesh }); break; + } + case 'sketch_pattern_linear': + if (!cur) throw new Error('缺少草图'); + sketchPatternLinear(doc, cur, op); + break; + case 'sketch_pattern_circular': + if (!cur) throw new Error('缺少草图'); + sketchPatternCircular(doc, cur, op); + break; case 'extrude': case 'cut': case 'revolve': { if (!cur) throw new Error(`${op.op} 缺少草图`); const f = doc.addFeature({ type: op.op, sketchId: cur.id, name: op.name, - params: { depth: op.depth ?? 10, flip: !!op.flip, symmetric: !!op.symmetric, throughAll: !!op.throughAll, revolveDegrees: op.revolveDegrees ?? 360, axisEntityId: op.axis ?? null }, + params: { + depth: op.depth ?? 10, + mode: op.mode || op.termination, + targetFaceRef: op.targetFaceRef, + targetBodyRef: op.targetBodyRef, + offset: Number(op.offset || 0), + flip: !!op.flip, + symmetric: !!op.symmetric, + throughAll: !!op.throughAll, + revolveDegrees: op.revolveDegrees ?? 360, + axisEntityId: op.axis ?? null, + }, }); created.features.push(f.id); break; @@ -760,9 +2285,102 @@ export function executeOps(doc, ops) { created.features.push(f.id); break; } + case 'sweep': { + const f = doc.addFeature({ + type: 'sweep', + name: op.name, + params: { + path: op.path, radius: op.radius, profile: op.profile, + frameMode: op.frameMode || 'parallelTransport', twistDegrees: op.twistDegrees || 0, + capStart: op.capStart !== false, capEnd: op.capEnd !== false, segments: op.segments ?? 32, + }, + }); + created.features.push(f.id); + break; + } + case 'loft': { + const f = doc.addFeature({ + type: 'loft', name: op.name, + params: { profiles: op.profiles, resampleCount: op.resampleCount ?? 32, capStart: op.capStart !== false, capEnd: op.capEnd !== false, alignment: op.alignment || 'auto' }, + }); + created.features.push(f.id); + break; + } + case 'rib': { + if (!cur) throw new Error('rib 缺少开放路径草图'); + const f = doc.addFeature({ + type: 'rib', sketchId: cur.id, name: op.name, + params: { + thickness: Number(op.thickness), extent: Number(op.extent ?? 10), + side: op.side || 'both', draftDegrees: Number(op.draftDegrees || 0), flip: !!op.flip, + }, + }); + created.features.push(f.id); + break; + } + case 'chamfer_edge': { + const f = doc.addFeature({ type: 'chamfer', name: op.name, params: { edgeRefs: [op.edgeRef], distance: Number(op.distance) } }); + created.features.push(f.id); + break; + } + case 'fillet_edge': { + const f = doc.addFeature({ type: 'fillet', name: op.name, params: { edgeRefs: [op.edgeRef], radius: Number(op.radius), segments: Number(op.segments ?? 8) } }); + created.features.push(f.id); + break; + } + case 'reference_plane': { + lastReference = doc.addReference({ + type: 'plane', name: op.name, + sourceRefs: [op.source || 'front'], + params: { kind: op.kind, distance: Number(op.distance || 0) }, + }); + created.references.push(lastReference.id); + break; + } default: throw new Error('未知 op: ' + op.op); } } return created; } + +function cloneEntityTranslated(e, dx, dy) { + const out = JSON.parse(JSON.stringify(e)); + if (out.c) { out.c[0] += dx; out.c[1] += dy; } + if (out.p1) { out.p1[0] += dx; out.p1[1] += dy; } + if (out.p2) { out.p2[0] += dx; out.p2[1] += dy; } + if (out.points) out.points = out.points.map(p => [p[0] + dx, p[1] + dy]); + delete out.id; + return out; +} + +function cloneEntityRotated(e, cx, cy, angle) { + const out = JSON.parse(JSON.stringify(e)); + const rot = p => { const x = p[0] - cx, y = p[1] - cy, c = Math.cos(angle), s = Math.sin(angle); return [cx + x * c - y * s, cy + x * s + y * c]; }; + if (out.c) out.c = rot(out.c); + if (out.p1) out.p1 = rot(out.p1); + if (out.p2) out.p2 = rot(out.p2); + if (out.points) out.points = out.points.map(rot); + if (out.rotation != null) out.rotation += angle; + delete out.id; + return out; +} + +function selectedSketchEntities(sk, ids) { + const wanted = ids?.length ? new Set(ids.map(String)) : new Set([String(sk.entities.at(-1)?.id)]); + return sk.entities.filter(e => wanted.has(String(e.id))); +} + +function sketchPatternLinear(doc, sk, op) { + const count = Math.max(2, Math.min(50, Number(op.count) || 2)), dx = Number(op.dx || 0), dy = Number(op.dy || 0); + const base = selectedSketchEntities(sk, op.entityIds); + if (!base.length) throw new Error('草图阵列没有可复制的实体'); + for (let i = 1; i < count; i++) for (const e of base) doc.addEntity(sk.id, cloneEntityTranslated(e, dx * i, dy * i)); +} + +function sketchPatternCircular(doc, sk, op) { + const count = Math.max(2, Math.min(50, Number(op.count) || 2)), cx = Number(op.cx || 0), cy = Number(op.cy || 0); + const base = selectedSketchEntities(sk, op.entityIds); + if (!base.length) throw new Error('草图阵列没有可复制的实体'); + for (let i = 1; i < count; i++) for (const e of base) doc.addEntity(sk.id, cloneEntityRotated(e, cx, cy, Math.PI * 2 * i / count)); +} diff --git a/js/doc.js b/js/doc.js index ba1bdac..cb2f2c7 100644 --- a/js/doc.js +++ b/js/doc.js @@ -1,6 +1,13 @@ // doc.js —— 零件文档模型:草图/特征/重建/序列化 import { nextId, findLoops, validateProfile } from './sketch.js'; -import { meshFromManifold, booleanOp, buildExtrude, buildRevolve, buildCylinder, getMod } from './geometry.js'; +import { meshFromManifold, manifoldFromMeshData, booleanOp, buildExtrude, buildRevolve, buildCylinder, buildSweep, buildLoft, buildChamferEdge, buildFilletEdge, getMod } from './geometry.js'; +import { PLANES, toWorld } from './math.js'; +import { samplePathSketch, profileFromSketch } from './features/sweep.js'; +import { resolveReference } from './features/reference.js'; +import { normalizeTermination, resolveToNext, resolveToFace } from './features/termination.js'; +import { buildRibTool } from './features/rib.js'; +import { solveSketchConstraints, resolveDimensionTable, resolveParamExpressions } from './features/constraints.js'; +import { assemblyFromJSON } from './features/assembly.js?v=20260810-assembly2'; export class Doc { constructor() { @@ -8,11 +15,20 @@ export class Doc { } reset() { + // reset() 也用于“新建零件”场景;先释放上一次重建得到的 WASM 实体,避免 + // 连续通过 AI 创建多个零件时旧主体残留或被后续筋/倒角特征误引用。 + try { this.bodyManifold?.delete?.(); } catch { /* 已释放的 WASM 对象无需重复删除 */ } this.name = '未命名零件'; this.units = 'mm'; this.sketches = []; // {id,name,plane,offset,entities[],constraints[],dims[]} + this.references = []; // 用户参考几何(内置 front/top/right 不重复保存) + this.dimensions = []; // 命名尺寸:{name,expression,value,unit} + this.parameters = {}; // 可被尺寸/特征引用的参数 + this.assembly = null; // 装配体文档(零件文档保持 null) + this.drawings = []; // 工程图文档列表 this.features = []; // {id,name,type,sketchId,params} this.sketchSeq = 0; + this.referenceSeq = 0; this.featureSeq = 0; this.bodyManifold = null; // 最近一次重建结果(世界系) this.bodyMesh = null; // {verts,tris} @@ -21,11 +37,12 @@ export class Doc { } // ---------- 草图 ---------- - addSketch({ plane = 'front', offset = 0, name = null } = {}) { + addSketch({ plane = 'front', planeRefId = null, offset = 0, name = null } = {}) { const sk = { id: nextId(), name: name || `草图${++this.sketchSeq}`, plane, + planeRefId, offset: offset || 0, entities: [], constraints: [], @@ -37,6 +54,64 @@ export class Doc { sketchById(id) { return this.sketches.find(s => s.id === id); } + // ---------- 参考几何 ---------- + referenceById(id) { + const key = typeof id === 'string' && /^\d+$/.test(id) ? Number(id) : id; + const user = this.references.find(r => r.id === key); + if (user) return user; + const p = PLANES[key]; + if (!p) return null; + return { + id: key, + name: p.name + '基准面', + type: 'plane', + origin: [0, 0, 0], + normal: p.normal.slice(), + xAxis: p.uAxis.slice(), + yAxis: p.vAxis.slice(), + sourceRefs: [], + params: {}, + }; + } + + addReference(data = {}) { + if (!['plane', 'axis', 'point', 'csys'].includes(data.type)) throw new Error('未知的参考几何类型'); + const ref = JSON.parse(JSON.stringify(data)); + ref.id = ref.id ?? nextId(); + ref.name = ref.name || `参考几何${++this.referenceSeq}`; + ref.sourceRefs = Array.isArray(ref.sourceRefs) ? ref.sourceRefs : []; + ref.params = ref.params || {}; + this.references.push(ref); + return ref; + } + + updateReference(id, patch) { + const ref = this.references.find(r => r.id === id); + if (!ref) return null; + Object.assign(ref, JSON.parse(JSON.stringify(patch || {})), { id }); + return ref; + } + + deleteReference(id) { + const usedBySketch = this.sketches.some(s => s.planeRefId === id); + const usedByReference = this.references.some(r => r.id !== id && (r.sourceRefs || []).includes(id)); + if (usedBySketch || usedByReference) return false; + this.references = this.references.filter(r => r.id !== id); + return true; + } + + resolveReference(idOrRef) { + return resolveReference(idOrRef, id => this.referenceById(id)); + } + + resolveSketch(sketch) { + if (!sketch || !sketch.planeRefId) return sketch; + const plane = this.resolveReference(sketch.planeRefId); + if (!plane || plane.type !== 'plane') throw new Error('草图引用的基准面无效'); + const yAxis = plane.yAxis || cross3(plane.normal, plane.xAxis); + return { ...sketch, plane: { ...plane, yAxis }, offset: sketch.offset || 0 }; + } + addEntity(sketchId, ent) { const sk = this.sketchById(sketchId); ent.id = nextId(); @@ -76,9 +151,27 @@ export class Doc { updateFeature(id, params) { const f = this.features.find(x => x.id === id); if (!f) return; - f.params = JSON.parse(JSON.stringify(params)); + f.params = { ...f.params, ...JSON.parse(JSON.stringify(params)) }; } + setDimension(name, expression, unit = 'mm') { + if (!/^[A-Za-z_]\w*$/.test(String(name))) throw new Error('尺寸名称必须以字母或下划线开头'); + const d = this.dimensions.find(x => x.name === name); + if (d) Object.assign(d, { expression: String(expression), unit }); + else this.dimensions.push({ name: String(name), expression: String(expression), unit }); + return d || this.dimensions.at(-1); + } + + setParameter(name, value) { + if (!/^[A-Za-z_]\w*$/.test(String(name))) throw new Error('参数名称无效'); + const n = Number(value); + if (!Number.isFinite(n)) throw new Error('参数值必须是数字'); + this.parameters[String(name)] = n; + return n; + } + + dimensionContext() { return resolveDimensionTable(this.dimensions, this.parameters); } + deleteFeature(id) { this.features = this.features.filter(f => f.id !== id); } @@ -86,7 +179,12 @@ export class Doc { featureById(id) { return this.features.find(f => f.id === id); } deleteSketch(id) { - const used = this.features.some(f => f.sketchId === id); + const used = this.features.some(f => + f.sketchId === id || + f.params?.pathSketchId === id || + f.params?.profileSketchId === id || + (Array.isArray(f.params?.profileSketchIds) && f.params.profileSketchIds.includes(id)) + ); if (used) return false; this.sketches = this.sketches.filter(s => s.id !== id); return true; @@ -95,17 +193,31 @@ export class Doc { // ---------- 重建 ---------- rebuild() { const mod = getMod(); + if (!mod) throw new Error('几何内核尚未初始化'); + if (this.bodyManifold) { + this.bodyManifold.delete(); + this.bodyManifold = null; + } let body = null; + const dimensions = this.dimensionContext(); + for (const sk of this.sketches) { + const source = sk._paramEntities || (containsParamRef(sk.entities) ? (sk._paramEntities = JSON.parse(JSON.stringify(sk.entities))) : sk.entities); + sk.entities = resolveParamExpressions(JSON.parse(JSON.stringify(source)), { ...dimensions, dimensions, parameters: this.parameters }); + sk.solveReport = solveSketchConstraints(sk, { dimensions, parameters: this.parameters }); + } + for (const f of this.features) f.params = resolveParamExpressions(f.params, { ...dimensions, dimensions, parameters: this.parameters }); const deltas = {}; for (const f of this.features) { + let tool = null; try { - const sk = f.sketchId ? this.sketchById(f.sketchId) : null; + const storedSketch = f.sketchId ? this.sketchById(f.sketchId) : null; + const sk = storedSketch ? this.resolveSketch(storedSketch) : null; if (f.sketchId && !sk) throw new Error('草图不存在'); - let tool = null; + let replaceBody = false; switch (f.type) { case 'extrude': case 'cut': if (!sk) throw new Error('缺少草图'); - tool = buildExtrude(sk, f.params, body ? body.boundingBox() : null); + tool = buildExtrude(sk, resolveExtrudeParams(this, sk, resolveParamExpressions(f.params, { ...dimensions, dimensions, parameters: this.parameters }), body), body ? body.boundingBox() : null); break; case 'revolve': if (!sk) throw new Error('缺少草图'); @@ -117,21 +229,72 @@ export class Doc { case 'cylcut': tool = buildCylinder(f.params, body ? body.boundingBox() : null); break; + case 'sweep': + if (f.params.pathSketchId || f.params.profileSketchId) { + const pathSketch = this.sketchById(f.params.pathSketchId); + const profileSketch = this.sketchById(f.params.profileSketchId); + if (!pathSketch || !profileSketch) throw new Error('扫描引用的路径或截面草图不存在'); + const path = samplePathSketch(this.resolveSketch(pathSketch)); + const profile = profileFromSketch(this.resolveSketch(profileSketch)); + tool = buildSweep({ ...f.params, path, profile }); + } else { + tool = buildSweep(f.params); + } + break; + case 'loft': { + let profiles = f.params.profiles; + if (!profiles && Array.isArray(f.params.profileSketchIds)) { + profiles = f.params.profileSketchIds.map(id => { + const profileSketch = this.sketchById(id); + if (!profileSketch) throw new Error(`放样引用的截面草图不存在:${id}`); + const profile = profileFromSketch(this.resolveSketch(profileSketch)); + if (profile.rings && profile.rings.length > 1) throw new Error('放样暂不支持带孔的多环截面'); + return profile; + }); + } + tool = buildLoft(profiles, f.params); + break; + } + case 'rib': + if (!sk) throw new Error('筋特征缺少开放路径草图'); + if (!body) throw new Error('筋特征必须建立在已有主体上'); + tool = buildRibTool({ manifoldFromMeshData, mod }, { sketch: sk, ...f.params, body }); + break; + case 'chamfer': + if (!body) throw new Error('实体倒角必须建立在已有主体上'); + tool = buildChamferEdge(body, f.params); + replaceBody = true; + break; + case 'fillet': + if (!body) throw new Error('实体圆角必须建立在已有主体上'); + tool = buildFilletEdge(body, f.params); + replaceBody = true; + break; } const dm = meshFromManifold(tool); deltas[f.id] = dm; const subtract = f.type === 'cut' || f.type === 'cylcut'; let nb; - if (!body) { + if (replaceBody) { + body.delete(); + body = tool; + tool = null; + f.error = null; + continue; + } else if (!body) { nb = tool; // 首个特征直接成为实体 + tool = null; } else { nb = booleanOp(body, tool, subtract); body.delete(); tool.delete(); + tool = null; } body = nb; f.error = null; } catch (e) { + // 失败特征不能污染现有主体;已构造但未被接管的临时工具必须释放。 + if (tool && tool !== body) tool.delete?.(); f.error = (e && e.message) || String(e); } } @@ -148,10 +311,15 @@ export class Doc { /** 从快照恢复文档状态;调用方需随后 rebuild() 刷新几何 */ restore(snap) { + if (this.bodyManifold) this.bodyManifold.delete(); const d = Doc.fromJSON(snap); this.name = d.name; this.units = d.units; this.sketches = d.sketches; this.features = d.features; - this.sketchSeq = d.sketchSeq; this.featureSeq = d.featureSeq; + this.references = d.references; + this.dimensions = d.dimensions; this.parameters = d.parameters; + this.assembly = d.assembly; + this.drawings = d.drawings; + this.sketchSeq = d.sketchSeq; this.referenceSeq = d.referenceSeq; this.featureSeq = d.featureSeq; this.bodyManifold = null; this.bodyMesh = null; this.deltaMeshes = {}; this.bbox = null; return this; } @@ -159,9 +327,19 @@ export class Doc { // ---------- 序列化 ---------- toJSON() { return { - app: 'minisw', version: 1, units: this.units, + app: 'minisw', version: 2, units: this.units, name: this.name, - sketches: this.sketches, + sketches: this.sketches.map(sk => { + const copy = JSON.parse(JSON.stringify(sk)); + if (sk._paramEntities) copy.entities = sk._paramEntities; + delete copy._paramEntities; delete copy.solveReport; + return copy; + }), + references: this.references, + dimensions: this.dimensions, + parameters: this.parameters, + assembly: this.assembly, + drawings: this.drawings, features: this.features.map(f => ({ id: f.id, name: f.name, type: f.type, sketchId: f.sketchId, params: f.params })), }; } @@ -171,15 +349,22 @@ export class Doc { doc.name = data.name || '未命名零件'; doc.units = data.units || 'mm'; doc.sketches = (data.sketches || []).map(s => JSON.parse(JSON.stringify(s))); + doc.references = (data.references || []).map(r => JSON.parse(JSON.stringify(r))); + doc.dimensions = (data.dimensions || []).map(d => JSON.parse(JSON.stringify(d))); + doc.parameters = { ...(data.parameters || {}) }; + doc.assembly = data.assembly ? assemblyFromJSON(data.assembly) : null; + doc.drawings = (data.drawings || []).map(d => JSON.parse(JSON.stringify(d))); doc.features = (data.features || []).map(f => JSON.parse(JSON.stringify(f))); // 重建 id 计数器 const all = []; for (const s of doc.sketches) { all.push(s.id); for (const e of s.entities) all.push(e.id); for (const c of s.constraints) all.push(c.id); for (const d of s.dims) all.push(d.id); } + for (const r of doc.references) all.push(r.id); for (const f of doc.features) all.push(f.id); const max = all.reduce((m, v) => Math.max(m, v || 0), 0); for (let i = 0; i <= max; i++) nextId(); doc.sketchSeq = doc.sketches.filter(s => /^草图\d+$/.test(s.name)).length; - doc.featureSeq = doc.features.filter(f => /^(拉伸|切除|旋转)/.test(f.name)).length; + doc.referenceSeq = doc.references.length; + doc.featureSeq = doc.features.filter(f => /^(拉伸|切除|旋转|扫描|放样|筋|实体倒角|实体圆角)/.test(f.name)).length; return doc; } } @@ -191,6 +376,68 @@ function featureTypeName(type) { case 'revolve': return '旋转'; case 'cylinder': return '圆柱'; case 'cylcut': return '圆柱切除'; + case 'sweep': return '扫描'; + case 'loft': return '放样'; + case 'rib': return '筋'; + case 'chamfer': return '实体倒角'; + case 'fillet': return '实体圆角'; } return type; } + +function cross3(a, b) { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} + +function containsParamRef(value) { + if (typeof value === 'string') return /^[A-Za-z_]\w*$/.test(value); + if (Array.isArray(value)) return value.some(containsParamRef); + if (value && typeof value === 'object') return Object.values(value).some(containsParamRef); + return false; +} + +/** 把统一终止条件解析为现有拉伸内核可消费的精确深度。 */ +function resolveExtrudeParams(doc, sketch, params, body) { + const term = normalizeTermination(params || {}); + const out = { + ...params, + mode: term.mode, + flip: term.flip, + throughAll: term.mode === 'throughAll', + symmetric: term.mode === 'symmetric', + }; + if (term.mode === 'blind' || term.mode === 'symmetric') { + out.depth = term.depth; + return out; + } + if (term.mode === 'throughAll') return out; + + const plane = sketch.plane; + const direction = (typeof plane === 'object' ? plane.normal : PLANES[plane]?.normal)?.slice(); + if (!direction) throw new Error('拉伸草图平面缺少有效法向'); + const origin = toWorld(0, 0, 0, plane, sketch.offset || 0, 1); + + if (term.mode === 'toNext' || term.mode === 'toBody') { + if (!body) throw new Error(`${term.mode === 'toNext' ? '到下一面' : '到实体'}终止需要已有目标实体`); + // 当前文档是单实体顺序特征树;toBody 的稳定目标因此只能是当前主体。 + if (term.mode === 'toBody' && !['current', 'body', 'currentBody'].includes(term.targetBodyRef)) { + throw new Error('当前单实体文档仅支持以 current 作为到实体目标'); + } + const hit = resolveToNext({ origin, direction, body, flip: term.flip }); + out.depth = hit.depth + term.offset; + if (!(out.depth > 1e-6)) throw new Error('终止偏置后的拉伸深度必须大于零'); + return out; + } + + const targetFace = typeof term.targetFaceRef === 'object' + ? term.targetFaceRef + : doc.resolveReference(term.targetFaceRef); + out.depth = resolveToFace({ + origin, + direction, + targetFace, + offset: term.offset, + flip: term.flip, + }).depth; + return out; +} diff --git a/js/features/assembly.js b/js/features/assembly.js new file mode 100644 index 0000000..d244b26 --- /dev/null +++ b/js/features/assembly.js @@ -0,0 +1,136 @@ +// 装配体数据与轻量配合/干涉求解。组件仍引用独立零件文档,装配只保存变换和关系。 +import { getMod, manifoldFromMeshData } from '../geometry.js'; + +function identityTransform() { return { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }; } +function clone(v) { return JSON.parse(JSON.stringify(v)); } +function add3(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; } +function sub3(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } +function rotate(p, r) { + let [x, y, z] = p; const [rx, ry, rz] = r.map(Number); + let c = Math.cos(rx), s = Math.sin(rx); [y, z] = [y * c - z * s, y * s + z * c]; + c = Math.cos(ry); s = Math.sin(ry); [x, z] = [x * c + z * s, -x * s + z * c]; + c = Math.cos(rz); s = Math.sin(rz); [x, y] = [x * c - y * s, x * s + y * c]; + return [x, y, z]; +} + +export function createAssembly(name = '未命名装配体') { + return { version: 1, name, components: [], mates: [], explode: { active: false, progress: 0 } }; +} + +export function addComponent(assembly, { id, name, docId = null, mesh = null, transform = {}, fixed = false, visible = true } = {}) { + if (!assembly || !id) throw new Error('装配组件需要稳定 id'); + if (assembly.components.some(c => c.id === id)) throw new Error('组件已存在:' + id); + const component = { id: String(id), name: name || String(id), docId, mesh: mesh ? { verts: Float32Array.from(mesh.verts), tris: Uint32Array.from(mesh.tris) } : null, transform: { ...identityTransform(), ...clone(transform) }, fixed: !!fixed, visible: visible !== false }; + assembly.components.push(component); + return component; +} + +export function removeComponent(assembly, id) { + const before = assembly.components.length; + assembly.components = assembly.components.filter(c => c.id !== id); + assembly.mates = assembly.mates.filter(m => m.refA?.componentId !== id && m.refB?.componentId !== id); + return before !== assembly.components.length; +} + +export function updateComponentTransform(assembly, id, patch = {}) { + const c = assembly.components.find(x => x.id === id); + if (!c) throw new Error('组件不存在:' + id); + c.transform = { ...c.transform, ...clone(patch) }; + return c; +} + +export function transformedMesh(mesh, transform = identityTransform()) { + if (!mesh?.verts || !mesh?.tris) return null; + const position = transform.position || [0, 0, 0], rotation = transform.rotation || [0, 0, 0], scale = transform.scale || [1, 1, 1]; + const verts = new Float32Array(mesh.verts.length); + for (let i = 0; i < mesh.verts.length; i += 3) { + const p = rotate([mesh.verts[i] * scale[0], mesh.verts[i + 1] * scale[1], mesh.verts[i + 2] * scale[2]], rotation); + verts[i] = p[0] + position[0]; verts[i + 1] = p[1] + position[1]; verts[i + 2] = p[2] + position[2]; + } + return { verts, tris: mesh.tris.slice() }; +} + +export function boundsOfMesh(mesh) { + if (!mesh?.verts?.length) return null; + const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; + for (let i = 0; i < mesh.verts.length; i += 3) for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], mesh.verts[i + k]); max[k] = Math.max(max[k], mesh.verts[i + k]); } + return { min, max }; +} + +function overlapBounds(a, b, tolerance = 1e-6) { + if (!a || !b) return null; + const min = [0, 1, 2].map(k => Math.max(a.min[k], b.min[k])), max = [0, 1, 2].map(k => Math.min(a.max[k], b.max[k])); + if (max.some((v, k) => v - min[k] <= tolerance)) return null; + return { min, max, volume: (max[0] - min[0]) * (max[1] - min[1]) * (max[2] - min[2]) }; +} + +export function checkInterference(a, b, { tolerance = 1e-6, precise = true } = {}) { + const ma = transformedMesh(a.mesh, a.transform), mb = transformedMesh(b.mesh, b.transform); + const overlap = overlapBounds(boundsOfMesh(ma), boundsOfMesh(mb), tolerance); + if (!overlap || !precise || !getMod()) return { interfering: !!overlap, volume: overlap?.volume || 0, bounds: overlap, componentA: a.id, componentB: b.id, precise: false }; + let aa = null, bb = null, intersection = null; + try { + aa = manifoldFromMeshData(ma, '装配组件A'); bb = manifoldFromMeshData(mb, '装配组件B'); + intersection = getMod().Manifold.intersection([aa, bb]); + const volume = intersection.volume(); + return { interfering: volume > tolerance, volume, bounds: overlap, componentA: a.id, componentB: b.id, precise: true }; + } catch { + return { interfering: !!overlap, volume: overlap.volume, bounds: overlap, componentA: a.id, componentB: b.id, precise: false }; + } finally { intersection?.delete?.(); aa?.delete?.(); bb?.delete?.(); } +} + +export function addMate(assembly, mate) { + const allowed = ['coincident', 'concentric', 'distance', 'parallel', 'perpendicular', 'angle']; + if (!allowed.includes(mate.type)) throw new Error('不支持的配合类型:' + mate.type); + if (!mate.refA?.componentId || !mate.refB?.componentId) throw new Error('配合需要两个组件引用'); + const out = { id: mate.id || 'mate-' + (assembly.mates.length + 1), type: mate.type, refA: clone(mate.refA), refB: clone(mate.refB), value: Number(mate.value || 0), suppressed: false }; + assembly.mates.push(out); return out; +} + +export function solveMates(assembly, { iterations = 4 } = {}) { + const reports = []; + for (let pass = 0; pass < iterations; pass++) for (const mate of assembly.mates) { + if (mate.suppressed) continue; + const a = assembly.components.find(c => c.id === mate.refA.componentId), b = assembly.components.find(c => c.id === mate.refB.componentId); + if (!a || !b) { reports.push({ id: mate.id, status: 'missing' }); continue; } + if (a.fixed && b.fixed) { reports.push({ id: mate.id, status: 'conflict' }); continue; } + const target = a.fixed ? b : a, anchor = a.fixed ? a : b; + const ar = mate.refA.position || [0, 0, 0], br = mate.refB.position || [0, 0, 0]; + const ap = add3(anchor.transform.position, a.fixed ? ar : br), tp = target.transform.position; + let desired = ap; + if (mate.type === 'distance') desired = add3(ap, [0, 0, mate.value]); + target.transform.position = sub3(desired, a.fixed ? br : ar); + reports.push({ id: mate.id, status: 'solved', residual: 0, component: target.id }); + } + return reports; +} + +export function setExplode(assembly, { progress = 1, vectors = {} } = {}) { + const p = Math.max(0, Math.min(1, Number(progress))); assembly.explode = { active: p > 0, progress: p }; + for (const c of assembly.components) { const v = vectors[c.id] || [0, 0, 0]; c.transform.position = add3(c.transform.position, v.map(n => Number(n) * p)); } + return assembly.explode; +} + +export function checkAllInterferences(assembly, options = {}) { + const out = []; + for (let i = 0; i < assembly.components.length; i++) for (let j = i + 1; j < assembly.components.length; j++) { + const result = checkInterference(assembly.components[i], assembly.components[j], options); if (result.interfering) out.push(result); + } + return out; +} + +export function assemblyToJSON(assembly) { return clone(assembly); } +function normalizeMesh(mesh) { + if (!mesh?.verts || !mesh?.tris) return null; + const values = value => Array.isArray(value) ? value : Object.values(value); + return { verts: Float32Array.from(values(mesh.verts)), tris: Uint32Array.from(values(mesh.tris)) }; +} + +export function assemblyFromJSON(data) { + const components = (data?.components || []).map(source => { + const component = clone(source); + component.mesh = normalizeMesh(source.mesh || component.mesh); + return component; + }); + return { ...createAssembly(data?.name), ...clone(data), components, mates: clone(data?.mates || []) }; +} diff --git a/js/features/chamfer.js b/js/features/chamfer.js new file mode 100644 index 0000000..e5284dc --- /dev/null +++ b/js/features/chamfer.js @@ -0,0 +1,189 @@ +// 受限的实体倒角:只处理方向一致的封闭凸 MeshData 上的一条直棱。 +// 不依赖 WASM;成功时返回新的 MeshData,所有数组所有权交给调用方。 + +const EPS = 1e-7; + +const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +const mul = (a, s) => [a[0] * s, a[1] * s, a[2] * s]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const length = a => Math.hypot(a[0], a[1], a[2]); +const norm = a => { const l = length(a); return l > EPS ? mul(a, 1 / l) : null; }; +const point = (verts, i) => [verts[i * 3], verts[i * 3 + 1], verts[i * 3 + 2]]; +const signedVolume = mesh => { let s = 0; for (let i = 0; i < mesh.tris.length; i += 3) { const a = point(mesh.verts, mesh.tris[i]), b = point(mesh.verts, mesh.tris[i + 1]), c = point(mesh.verts, mesh.tris[i + 2]); s += dot(a, cross(b, c)); } return s / 6; }; +const keyOf = (p, tol) => p.map(x => Math.round(x / tol)).join(','); +const sameNormal = (a, b, tol) => dot(a, b) > 1 - Math.max(tol * 10, 1e-6); + +function triangleNormal(mesh, triIndex) { + const a = point(mesh.verts, mesh.tris[triIndex]), b = point(mesh.verts, mesh.tris[triIndex + 1]), c = point(mesh.verts, mesh.tris[triIndex + 2]); + const n = norm(cross(sub(b, a), sub(c, a))); + if (!n) throw new Error('网格含退化三角形,不能创建倒角'); + return n; +} + +function assertMesh(mesh, tol) { + if (!mesh || !mesh.verts || !mesh.tris || mesh.verts.length < 12 || mesh.tris.length < 12 || mesh.verts.length % 3 || mesh.tris.length % 3) throw new Error('倒角需要封闭 MeshData 输入'); + if (!(tol > 0)) throw new Error('倒角容差必须为正数'); + const uses = new Map(); + for (let i = 0; i < mesh.tris.length; i += 3) { + const ids = [mesh.tris[i], mesh.tris[i + 1], mesh.tris[i + 2]]; + if (ids.some(id => !Number.isInteger(id) || id < 0 || id * 3 + 2 >= mesh.verts.length)) throw new Error('网格三角形索引无效'); + const ps = ids.map(id => point(mesh.verts, id)); + if (!norm(cross(sub(ps[1], ps[0]), sub(ps[2], ps[0])))) throw new Error('网格含退化三角形,不能创建倒角'); + for (let k = 0; k < 3; k++) { + const ka = keyOf(ps[k], tol), kb = keyOf(ps[(k + 1) % 3], tol); + const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + const list = uses.get(key) || []; list.push(i); uses.set(key, list); + } + } + for (const [key, list] of uses) if (list.length !== 2) throw new Error(`网格不是封闭二流形,不能创建倒角(边 ${key} 邻接 ${list.length} 个面)`); + if (signedVolume(mesh) <= tol * tol * tol) throw new Error('网格面朝向不一致或体积退化,不能创建倒角'); +} + +/** + * 提取由量化几何端点和相邻三角形法向定义的棱边。 + * 仅暴露真实折棱;共面三角形的内部对角线会被过滤。 + */ +export function extractTopology(mesh, tolerance = 1e-6) { + assertMesh(mesh, tolerance); + const raw = new Map(); + for (let ti = 0; ti < mesh.tris.length; ti += 3) { + const n = triangleNormal(mesh, ti); + for (let k = 0; k < 3; k++) { + const ia = mesh.tris[ti + k], ib = mesh.tris[ti + (k + 1) % 3]; + const a = point(mesh.verts, ia), b = point(mesh.verts, ib); + const ka = keyOf(a, tolerance), kb = keyOf(b, tolerance); + const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + const entries = raw.get(key) || []; + entries.push({ a, b, normal: n, triIndex: ti }); raw.set(key, entries); + } + } + const edges = []; + for (const [key, entries] of raw) { + if (entries.length !== 2) throw new Error('网格不是封闭二流形,无法提取棱边'); + if (sameNormal(entries[0].normal, entries[1].normal, tolerance)) continue; + const a = entries[0].a, b = entries[0].b, d = norm(sub(b, a)); + if (!d) continue; + edges.push({ key, a, b, direction: d, length: length(sub(b, a)), normals: [entries[0].normal, entries[1].normal], triangleIndices: [entries[0].triIndex, entries[1].triIndex] }); + } + return { mesh, tolerance, edges }; +} + +/** 用端点和两侧面法向生成可 JSON 序列化的稳定棱边引用。 */ +export function makeStableEdgeRef(edge, topology) { + if (!edge || !topology) throw new Error('缺少棱边或拓扑信息'); + const tol = topology.tolerance; + const endpoints = [edge.a, edge.b].map(p => p.slice()).sort((a, b) => keyOf(a, tol).localeCompare(keyOf(b, tol))); + const faceNormals = edge.normals.map(n => n.slice()).sort((a, b) => keyOf(a, tol).localeCompare(keyOf(b, tol))); + return { type: 'mesh-edge', version: 1, endpoints, faceNormals, length: edge.length, signature: `${keyOf(endpoints[0], tol)}|${keyOf(endpoints[1], tol)}|${faceNormals.map(n => keyOf(n, tol)).join('|')}` }; +} + +/** 重建后只能唯一匹配才解析,歧义或失效均明确报错。 */ +export function resolveEdgeRef(ref, topology) { + if (!ref || ref.type !== 'mesh-edge' || !Array.isArray(ref.endpoints) || !Array.isArray(ref.faceNormals)) throw new Error('棱边引用格式无效'); + const tol = topology.tolerance; + const matches = topology.edges.filter(edge => { + const e = makeStableEdgeRef(edge, topology); + if (typeof ref.signature === 'string') return e.signature === ref.signature; + return e.endpoints.every((p, i) => length(sub(p, ref.endpoints[i])) <= tol) && e.faceNormals.every((n, i) => sameNormal(n, ref.faceNormals[i], tol)); + }); + if (!matches.length) throw new Error('棱边引用已失效或目标边不存在'); + if (matches.length > 1) throw new Error('棱边引用不唯一,无法安全定位目标边'); + return matches[0]; +} + +function centroid(mesh) { const c = [0, 0, 0], n = mesh.verts.length / 3; for (let i = 0; i < n; i++) { c[0] += mesh.verts[i * 3]; c[1] += mesh.verts[i * 3 + 1]; c[2] += mesh.verts[i * 3 + 2]; } return mul(c, 1 / n); } +function projectionOnLine(p, a, d) { return add(a, mul(d, dot(sub(p, a), d))); } +function sideDirection(mesh, edge, normal) { + let best = null, bestDist = 0; + for (let i = 0; i < mesh.verts.length / 3; i++) { + const p = point(mesh.verts, i); + if (Math.abs(dot(normal, sub(p, edge.a))) > 1e-5) continue; + const v = sub(p, projectionOnLine(p, edge.a, edge.direction)), l = length(v); + if (l > bestDist) { best = v; bestDist = l; } + } + if (!best) throw new Error('无法确定棱边相邻面的可用宽度'); + return { direction: mul(best, 1 / bestDist), available: bestDist }; +} + +function unique(points, tol) { const out = []; const seen = new Set(); for (const p of points) { const k = keyOf(p, tol); if (!seen.has(k)) { seen.add(k); out.push(p); } } return out; } +function clipTriangle(tri, planePoint, planeNormal, retainedSign, tol, cuts) { + const value = p => retainedSign * dot(planeNormal, sub(p, planePoint)); + const out = []; + for (let i = 0; i < 3; i++) { + const a = tri[i], b = tri[(i + 1) % 3], va = value(a), vb = value(b); + const inA = va >= -tol, inB = vb >= -tol; + if (inA) out.push(a); + if (inA !== inB) { const t = va / (va - vb); const p = add(a, mul(sub(b, a), t)); out.push(p); cuts.push(p); } + } + return out; +} +function pushTriangle(out, a, b, c, tol) { if (length(cross(sub(b, a), sub(c, a))) > tol * tol) out.push(a, b, c); } +function capTriangles(cuts, planePoint, normal, desiredNormal, tol, out) { + const pts = unique(cuts, tol); + if (pts.length < 3) throw new Error('倒角截面退化,请减小倒角距离'); + // 不能假设第一个交点不同于 planePoint;直接从封口法向构造稳定平面基。 + const reference = Math.abs(desiredNormal[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const u = norm(cross(desiredNormal, reference)); + const v = u && norm(cross(desiredNormal, u)); + if (!u || !v) throw new Error('倒角截面退化,请减小倒角距离'); + const center = pts.reduce((s, p) => add(s, p), [0, 0, 0]).map(x => x / pts.length); + pts.sort((a, b) => Math.atan2(dot(sub(a, center), v), dot(sub(a, center), u)) - Math.atan2(dot(sub(b, center), v), dot(sub(b, center), u))); + const test = cross(sub(pts[1], pts[0]), sub(pts[2], pts[0])); + if (dot(test, desiredNormal) < 0) pts.reverse(); + // 中心扇形逐段复用裁剪边。原三角形对角线可能把封口边分成共线小段, + // 固定角点扇形会跳过这些零面积扇片并留下 T 型缝。 + for (let i = 0; i < pts.length; i++) pushTriangle(out, center, pts[i], pts[(i + 1) % pts.length], tol); +} + +/** + * 对一条凸直棱生成真实平面倒角。限制:整个输入必须为方向一致的封闭凸多面体; + * 一次只能处理一条棱,以避免端点处多倒角相交的组合拓扑问题。 + */ +export function buildChamferMesh({ mesh, edgeRefs, distance, tolerance = 1e-6 } = {}) { + const topology = extractTopology(mesh, tolerance); + if (!Array.isArray(edgeRefs) || edgeRefs.length !== 1) throw new Error('当前倒角仅支持选择一条凸直棱边'); + if (!(Number.isFinite(distance) && distance > tolerance)) throw new Error('倒角距离必须为大于容差的正数'); + const edge = edgeRefs[0].a ? edgeRefs[0] : resolveEdgeRef(edgeRefs[0], topology); + // 严格凸性验证:每个外向三角面外侧不得存在任何顶点。 + for (let ti = 0; ti < mesh.tris.length; ti += 3) { + const a = point(mesh.verts, mesh.tris[ti]), n = triangleNormal(mesh, ti); + for (let i = 0; i < mesh.verts.length / 3; i++) if (dot(n, sub(point(mesh.verts, i), a)) > tolerance * 8) throw new Error('当前倒角只支持凸实体;检测到凹边或非凸拓扑'); + } + const sa = sideDirection(mesh, edge, edge.normals[0]), sb = sideDirection(mesh, edge, edge.normals[1]); + if (distance >= Math.min(sa.available, sb.available) - tolerance) throw new Error('倒角距离过大,超过相邻面的可用宽度'); + const pa = add(edge.a, mul(sa.direction, distance)), pb = add(edge.a, mul(sb.direction, distance)); + let planeNormal = norm(cross(edge.direction, sub(pb, pa))); + if (!planeNormal) throw new Error('相邻面退化,无法生成倒角'); + const inside = centroid(mesh), retainedSign = dot(planeNormal, sub(inside, pa)) >= 0 ? 1 : -1; + const desiredCapNormal = mul(planeNormal, -retainedSign); + const triangles = [], cuts = []; + for (let ti = 0; ti < mesh.tris.length; ti += 3) { + const tri = [point(mesh.verts, mesh.tris[ti]), point(mesh.verts, mesh.tris[ti + 1]), point(mesh.verts, mesh.tris[ti + 2])]; + const poly = clipTriangle(tri, pa, planeNormal, retainedSign, tolerance, cuts); + // 也记录恰好落在裁剪面上的既有顶点,避免原三角形对角线把封口边拆分后出现 T 型缝。 + for (const p of poly) if (Math.abs(dot(planeNormal, sub(p, pa))) <= tolerance * 4) cuts.push(p); + for (let i = 1; i < poly.length - 1; i++) pushTriangle(triangles, poly[0], poly[i], poly[i + 1], tolerance); + } + capTriangles(cuts, pa, planeNormal, desiredCapNormal, tolerance, triangles); + if (!triangles.length) throw new Error('倒角结果为空,请检查目标边和距离'); + // 输出必须共享索引;逐三角形拆点即使坐标相同,也会被内核视为裂缝而拒绝。 + const vertices = [], indices = [], vertexByKey = new Map(); + const indexOf = p => { + const key = keyOf(p, tolerance); + const old = vertexByKey.get(key); + if (old !== undefined) return old; + const index = vertices.length / 3; + vertices.push(p[0], p[1], p[2]); vertexByKey.set(key, index); + return index; + }; + for (let i = 0; i < triangles.length; i += 3) { + const a = indexOf(triangles[i]), b = indexOf(triangles[i + 1]), c = indexOf(triangles[i + 2]); + if (a !== b && b !== c && c !== a) indices.push(a, b, c); + } + const verts = new Float32Array(vertices), tris = new Uint32Array(indices); + // 复用拓扑提取器验证每条几何边恰好被两个三角面使用。 + extractTopology({ verts, tris }, tolerance); + return { verts, tris, selectedEdges: [{ start: edge.a.slice(), end: edge.b.slice() }] }; +} diff --git a/js/features/constraints.js b/js/features/constraints.js new file mode 100644 index 0000000..9114b7e --- /dev/null +++ b/js/features/constraints.js @@ -0,0 +1,163 @@ +// 轻量 2D 参数化约束求解器。 +// 目标不是替代工业级 DCM,而是让常见课程草图在改尺寸后稳定重建。 + +const EPS = 1e-7; + +function num(v, fallback = 0) { + const n = Number(v); + return Number.isFinite(n) ? n : fallback; +} + +function entity(sk, id) { return sk.entities.find(e => String(e.id) === String(id)); } + +function point(sk, key) { + const m = String(key || '').match(/^(.+):(p0|p1|c)$/); + if (!m) return null; + const e = entity(sk, m[1]); + if (!e) return null; + if (m[2] === 'c') return e.c || null; + return m[2] === 'p0' ? (e.p1 || null) : (e.p2 || null); +} + +function lineLength(e) { return Math.hypot(e.p2[0] - e.p1[0], e.p2[1] - e.p1[1]); } +function normalize(x, y) { const l = Math.hypot(x, y) || 1; return [x / l, y / l]; } +function setLineLength(e, length, preserve = 'mid') { + const dx = e.p2[0] - e.p1[0], dy = e.p2[1] - e.p1[1]; + const d = normalize(dx, dy); + if (preserve === 'start') e.p2 = [e.p1[0] + d[0] * length, e.p1[1] + d[1] * length]; + else if (preserve === 'end') e.p1 = [e.p2[0] - d[0] * length, e.p2[1] - d[1] * length]; + else { + const mx = (e.p1[0] + e.p2[0]) / 2, my = (e.p1[1] + e.p2[1]) / 2; + e.p1 = [mx - d[0] * length / 2, my - d[1] * length / 2]; + e.p2 = [mx + d[0] * length / 2, my + d[1] * length / 2]; + } +} + +function targetOf(c, context) { + if (c.value != null) return evaluateExpression(c.value, context); + if (c.target != null) return evaluateExpression(c.target, context); + if (c.distance != null) return evaluateExpression(c.distance, context); + return null; +} + +export function evaluateExpression(value, context = {}) { + if (typeof value === 'number') return value; + if (value == null || value === '') return 0; + const text = String(value).trim().replace(/毫米|公厘|mm/gi, ''); + if (/^[A-Za-z_][\w.]*$/.test(text)) return num(context[text] ?? context.parameters?.[text] ?? context.dimensions?.[text], NaN); + if (!/^[0-9+\-*/(). _A-Za-z]+$/.test(text)) return NaN; + const names = { ...context.parameters, ...context.dimensions, ...context }; + const expr = text.replace(/[A-Za-z_]\w*/g, name => Number.isFinite(Number(names[name])) ? String(names[name]) : 'NaN'); + try { + // 表达式已通过白名单和变量替换;仅用于本地尺寸参数,不执行外部输入。 + const result = Function(`"use strict"; return (${expr})`)(); + return Number.isFinite(result) ? result : NaN; + } catch { return NaN; } +} + +export function resolveDimensionTable(dimensions = [], parameters = {}) { + const out = { ...parameters }; + const pending = [...dimensions]; + for (let pass = 0; pass < pending.length + 2; pass++) { + let changed = false; + for (const d of pending) { + const v = evaluateExpression(d.expression ?? d.value, { ...out, dimensions: out }); + if (Number.isFinite(v) && v > 0) { if (out[d.name] !== v) changed = true; out[d.name] = v; } + } + if (!changed) break; + } + return out; +} + +function residualFor(c, sk, ctx) { + const a = c.refs?.[0], b = c.refs?.[1]; + const ea = entity(sk, a), eb = entity(sk, b); + if (c.type === 'horizontal' && ea?.p1 && ea?.p2) return Math.abs(ea.p1[1] - ea.p2[1]); + if (c.type === 'vertical' && ea?.p1 && ea?.p2) return Math.abs(ea.p1[0] - ea.p2[0]); + if (c.type === 'coincident') { const p = point(sk, a), q = point(sk, b); return p && q ? Math.hypot(p[0] - q[0], p[1] - q[1]) : 1; } + if (c.type === 'equal' && ea && eb) { + const va = ea.r ?? (ea.p1 ? lineLength(ea) : 0), vb = eb.r ?? (eb.p1 ? lineLength(eb) : 0); return Math.abs(va - vb); + } + if (c.type === 'parallel' && ea?.p1 && ea?.p2 && eb?.p1 && eb?.p2) { + const da = normalize(ea.p2[0] - ea.p1[0], ea.p2[1] - ea.p1[1]); const db = normalize(eb.p2[0] - eb.p1[0], eb.p2[1] - eb.p1[1]); return Math.abs(da[0] * db[1] - da[1] * db[0]); + } + if (c.type === 'perpendicular' && ea?.p1 && ea?.p2 && eb?.p1 && eb?.p2) { + const da = normalize(ea.p2[0] - ea.p1[0], ea.p2[1] - ea.p1[1]); const db = normalize(eb.p2[0] - eb.p1[0], eb.p2[1] - eb.p1[1]); return Math.abs(da[0] * db[0] + da[1] * db[1]); + } + if (c.type === 'distance') { const p = point(sk, a), q = point(sk, b); const v = targetOf(c, ctx); return p && q && Number.isFinite(v) ? Math.abs(Math.hypot(q[0] - p[0], q[1] - p[1]) - v) : 1; } + if ((c.type === 'length' || c.type === 'distanceLength') && ea?.p1) { const v = targetOf(c, ctx); return Number.isFinite(v) ? Math.abs(lineLength(ea) - v) : 1; } + if (c.type === 'radius' || c.type === 'diameter') { const v = targetOf(c, ctx); const actual = c.type === 'diameter' ? (ea?.r || 0) * 2 : ea?.r || 0; return Number.isFinite(v) ? Math.abs(actual - v) : 1; } + if (c.type === 'tangent' && ea?.p1 && eb?.c && eb.r != null) { const dx = ea.p2[0] - ea.p1[0], dy = ea.p2[1] - ea.p1[1]; return Math.abs(Math.abs((eb.c[0] - ea.p1[0]) * dy - (eb.c[1] - ea.p1[1]) * dx) / (Math.hypot(dx, dy) || 1) - eb.r); } + if (c.type === 'angle' && ea?.p1 && ea?.p2) { const v = targetOf(c, ctx); return Number.isFinite(v) ? Math.abs(Math.atan2(ea.p2[1] - ea.p1[1], ea.p2[0] - ea.p1[0]) - v * Math.PI / 180) : 1; } + return 0; +} + +export function solveSketchConstraints(sketch, { dimensions = {}, parameters = {}, iterations = 60, tolerance = 1e-5 } = {}) { + const ctx = { ...parameters, ...dimensions, dimensions, parameters }; + const constraints = sketch.constraints || []; + const fixed = new Map(); + for (const c of constraints) { + if (c.type !== 'fixed') continue; + for (const ref of c.refs || []) { const p = point(sketch, ref); if (p) fixed.set(ref, p.slice()); } + } + let maxResidual = Infinity; + for (let pass = 0; pass < iterations; pass++) { + for (const c of constraints) { + const [a, b] = c.refs || []; + const ea = entity(sketch, a), eb = entity(sketch, b); + switch (c.type) { + case 'horizontal': if (ea?.p1 && ea?.p2) { const y = (ea.p1[1] + ea.p2[1]) / 2; ea.p1[1] = y; ea.p2[1] = y; } break; + case 'vertical': if (ea?.p1 && ea?.p2) { const x = (ea.p1[0] + ea.p2[0]) / 2; ea.p1[0] = x; ea.p2[0] = x; } break; + case 'coincident': { const p = point(sketch, a), q = point(sketch, b); if (p && q) { q[0] = p[0]; q[1] = p[1]; } break; } + case 'equal': if (ea && eb) { const va = ea.r ?? (ea.p1 ? lineLength(ea) : null); if (va != null) { if (eb.r != null) eb.r = va; else if (eb.p1) setLineLength(eb, va); } } break; + case 'parallel': if (ea?.p1 && ea?.p2 && eb?.p1 && eb?.p2) { const d = normalize(ea.p2[0] - ea.p1[0], ea.p2[1] - ea.p1[1]); const l = lineLength(eb); eb.p2 = [eb.p1[0] + d[0] * l, eb.p1[1] + d[1] * l]; } break; + case 'perpendicular': if (ea?.p1 && ea?.p2 && eb?.p1 && eb?.p2) { const d = normalize(ea.p2[0] - ea.p1[0], ea.p2[1] - ea.p1[1]); const l = lineLength(eb); eb.p2 = [eb.p1[0] - d[1] * l, eb.p1[1] + d[0] * l]; } break; + case 'distance': { const p = point(sketch, a), q = point(sketch, b), v = targetOf(c, ctx); if (p && q && Number.isFinite(v)) { const d = normalize(q[0] - p[0], q[1] - p[1]); q[0] = p[0] + d[0] * v; q[1] = p[1] + d[1] * v; } break; } + case 'length': case 'distanceLength': { const v = targetOf(c, ctx); if (ea?.p1 && Number.isFinite(v)) setLineLength(ea, v); break; } + case 'radius': case 'diameter': { const v = targetOf(c, ctx); if (ea && Number.isFinite(v)) ea.r = c.type === 'diameter' ? v / 2 : v; break; } + case 'tangent': if (ea?.p1 && eb?.c && eb.r != null) { const dx = ea.p2[0] - ea.p1[0], dy = ea.p2[1] - ea.p1[1], l = Math.hypot(dx, dy) || 1, n = [-dy / l, dx / l]; const side = ((eb.c[0] - ea.p1[0]) * n[0] + (eb.c[1] - ea.p1[1]) * n[1]) >= 0 ? 1 : -1; eb.c[0] = ea.p1[0] + n[0] * eb.r * side; eb.c[1] = ea.p1[1] + n[1] * eb.r * side; } break; + case 'angle': { const v = targetOf(c, ctx); if (ea?.p1 && ea?.p2 && Number.isFinite(v)) { const l = lineLength(ea), a0 = v * Math.PI / 180; ea.p2 = [ea.p1[0] + l * Math.cos(a0), ea.p1[1] + l * Math.sin(a0)]; } break; } + case 'fixed': break; + } + } + for (const [key, p] of fixed) { const q = point(sketch, key); if (q) { q[0] = p[0]; q[1] = p[1]; } } + maxResidual = constraints.reduce((m, c) => Math.max(m, residualFor(c, sketch, ctx)), 0); + if (maxResidual <= tolerance) break; + } + const conflicts = detectConflicts(constraints, ctx); + const status = conflicts.length ? 'over-constrained' : constraints.length === 0 ? 'under-constrained' : maxResidual <= tolerance ? 'solved' : 'under-constrained'; + return { status, residual: maxResidual, iterations, constrained: constraints.length, dof: estimateDof(sketch, constraints), overconstrained: conflicts.length > 0, conflicts }; +} + +function estimateDof(sketch, constraints) { + let vars = 0; + for (const e of sketch.entities || []) vars += e.type === 'circle' || e.type === 'arc' || e.type === 'ellipse' ? 3 : e.type === 'line' || e.type === 'centerline' ? 4 : 2; + return Math.max(0, vars - constraints.length); +} + +function detectConflicts(constraints, ctx) { + const seen = new Map(), out = []; + for (const c of constraints) { + if (!['length', 'distanceLength', 'radius', 'diameter', 'distance', 'angle'].includes(c.type)) continue; + const k = c.type + ':' + JSON.stringify(c.refs || []); + const v = targetOf(c, ctx); + if (!Number.isFinite(v)) continue; + if (seen.has(k) && Math.abs(seen.get(k) - v) > 1e-6) out.push({ refs: c.refs, type: c.type, values: [seen.get(k), v] }); + else seen.set(k, v); + } + return out; +} + +export function constraintSummary(sketch, options = {}) { + const report = solveSketchConstraints(sketch, options); + return { ...report, total: (sketch.constraints || []).length }; +} + +export function resolveParamExpressions(value, context) { + if (Array.isArray(value)) return value.map(v => resolveParamExpressions(v, context)); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveParamExpressions(v, context)])); + if (typeof value === 'string' && /^[A-Za-z_]\w*$/.test(value)) { + const n = evaluateExpression(value, context); return Number.isFinite(n) ? n : value; + } + return value; +} diff --git a/js/features/drawing.js b/js/features/drawing.js new file mode 100644 index 0000000..7e24bec --- /dev/null +++ b/js/features/drawing.js @@ -0,0 +1,83 @@ +// 工程图基础模块:从三角网格生成可编辑的 SVG 三视图、尺寸和剖视数据。 + +const SHEETS = { A4: { width: 297, height: 210 }, A3: { width: 420, height: 297 } }; +function clone(v) { return JSON.parse(JSON.stringify(v)); } +function key(a, b) { return a < b ? a + ':' + b : b + ':' + a; } + +export function createDrawing({ name = '未命名工程图', format = 'A4', orientation = 'landscape', scale = 1, units = 'mm' } = {}) { + const base = SHEETS[format] || SHEETS.A4; + const sheet = orientation === 'portrait' ? { width: base.height, height: base.width } : { ...base }; + return { version: 1, name, format, orientation, scale, units, sheet, views: [], dimensions: [], notes: [], title: { partName: '', material: '', author: '', revision: 'A' } }; +} + +function vertices(mesh) { const out = []; for (let i = 0; i < mesh.verts.length; i += 3) out.push([mesh.verts[i], mesh.verts[i + 1], mesh.verts[i + 2]]); return out; } +function projectPoint(p, view) { if (view === 'top') return [p[0], p[2]]; if (view === 'left') return [-p[1], p[2]]; return [p[0], p[1]]; } + +export function projectMesh(mesh, view = 'front', { hidden = false } = {}) { + if (!mesh?.verts || !mesh?.tris) return { view, segments: [], bounds: null }; + const vs = vertices(mesh), segments = [], seen = new Set(), edgeSet = new Set(); + for (let i = 0; i < mesh.tris.length; i += 3) for (const pair of [[mesh.tris[i], mesh.tris[i + 1]], [mesh.tris[i + 1], mesh.tris[i + 2]], [mesh.tris[i + 2], mesh.tris[i]]]) edgeSet.add(key(pair[0], pair[1])); + for (const ek of edgeSet) { + const [a, b] = ek.split(':').map(Number), pa = projectPoint(vs[a], view), pb = projectPoint(vs[b], view); + const sig = [pa, pb].flat().map(n => Number(n).toFixed(5)).join(','), rev = [pb, pa].flat().map(n => Number(n).toFixed(5)).join(','); + if (seen.has(sig) || seen.has(rev)) continue; + seen.add(sig); seen.add(rev); segments.push({ a: pa, b: pb, hidden: !!hidden }); + } + const pts = segments.flatMap(s => [s.a, s.b]); + const bounds = pts.length ? { min: [Math.min(...pts.map(p => p[0])), Math.min(...pts.map(p => p[1]))], max: [Math.max(...pts.map(p => p[0])), Math.max(...pts.map(p => p[1]))] } : null; + return { view, segments, bounds }; +} + +export function layoutThreeViews(mesh, { scale = 1, origin = [148.5, 105] } = {}) { + return [ + { id: 'front', name: '主视图', view: 'front', x: origin[0] - 45, y: origin[1] + 35, scale, projection: projectMesh(mesh, 'front') }, + { id: 'top', name: '俯视图', view: 'top', x: origin[0] - 45, y: origin[1] - 35, scale, projection: projectMesh(mesh, 'top') }, + { id: 'left', name: '左视图', view: 'left', x: origin[0] + 55, y: origin[1] + 35, scale, projection: projectMesh(mesh, 'left') }, + ]; +} + +export function addView(drawing, view) { drawing.views = drawing.views.filter(v => v.id !== view.id); drawing.views.push(clone(view)); return view; } +export function addDimension(drawing, data) { const d = { id: data.id || 'dim-' + (drawing.dimensions.length + 1), kind: data.kind || 'linear', value: Number(data.value), text: data.text || String(data.value), a: data.a || [0, 0], b: data.b || [0, 0], tolerance: data.tolerance || '' }; drawing.dimensions.push(d); return d; } +export function addNote(drawing, text, position = [0, 0]) { const n = { id: 'note-' + (drawing.notes.length + 1), text: String(text), position: position.slice() }; drawing.notes.push(n); return n; } +function sectionProjection(mesh, plane) { + if (!mesh?.verts || !mesh?.tris) return { view: 'section', segments: [], bounds: null }; + const o = plane.origin || [0, 0, 0], n = plane.normal || [1, 0, 0], vs = vertices(mesh), segments = []; + const signed = p => (p[0] - o[0]) * n[0] + (p[1] - o[1]) * n[1] + (p[2] - o[2]) * n[2]; + const project = p => n[0] ? [p[1], p[2]] : n[1] ? [p[0], p[2]] : [p[0], p[1]]; + for (let i = 0; i < mesh.tris.length; i += 3) { + const tri = [vs[mesh.tris[i]], vs[mesh.tris[i + 1]], vs[mesh.tris[i + 2]]], hits = []; + for (let j = 0; j < 3; j++) { const a = tri[j], b = tri[(j + 1) % 3], sa = signed(a), sb = signed(b); if (Math.abs(sa) < 1e-6) hits.push(a); if (sa * sb < -1e-12) { const t = sa / (sa - sb); hits.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]); } } + if (hits.length >= 2) segments.push({ a: project(hits[0]), b: project(hits[1]), hidden: false }); + } + const pts = segments.flatMap(s => [s.a, s.b]); + return { view: 'section', segments, bounds: pts.length ? { min: [Math.min(...pts.map(p => p[0])), Math.min(...pts.map(p => p[1]))], max: [Math.max(...pts.map(p => p[0])), Math.max(...pts.map(p => p[1]))] } : null }; +} +export function addSectionView(drawing, { id = 'section-A', name = 'A-A 剖视图', plane = { origin: [0, 0, 0], normal: [1, 0, 0] }, hatch = true, mesh = null } = {}) { const view = { id, name, kind: 'section', plane: clone(plane), hatch: !!hatch, status: 'linked', projection: sectionProjection(mesh, plane) }; drawing.views.push(view); return view; } + +function esc(s) { return String(s).replace(/[&<>"']/g, function(c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; }); } +function mapPoint(view, p) { return [view.x + p[0] * view.scale, view.y - p[1] * view.scale]; } + +export function renderDrawingSVG(drawing) { + const width = drawing.sheet.width, height = drawing.sheet.height; + const lines = ['', '', '']; + for (const view of drawing.views || []) { + if (!view.projection || !view.projection.segments?.length) { + if (view.kind === 'section') lines.push('A-A 剖视图(关联视图)'); + continue; + } + lines.push('' + esc(view.name || view.id) + ''); + for (const s of view.projection.segments || []) { const a = mapPoint(view, s.a), b = mapPoint(view, s.b); lines.push(''); } + if (view.kind === 'section' && view.hatch && view.projection.bounds) { + const b = view.projection.bounds; + for (let x = b.min[0]; x < b.max[0] + (b.max[1] - b.min[1]); x += 4) { const a = mapPoint(view, [x, b.min[1]]), c = mapPoint(view, [x - (b.max[1] - b.min[1]), b.max[1]]); lines.push(''); } + } + } + for (const d of drawing.dimensions || []) { const x1 = d.a[0], y1 = d.a[1], x2 = d.b[0], y2 = d.b[1]; lines.push('' + esc(d.text + (d.tolerance ? ' ' + d.tolerance : '')) + ''); } + for (const n of drawing.notes || []) lines.push('' + esc(n.text) + ''); + const t = drawing.title || {}, tx = width - 95, ty = height - 35; + lines.push('零件:' + esc(t.partName || drawing.name) + '材料:' + esc(t.material || '—') + '比例:' + esc(String(drawing.scale)) + ' 单位:' + esc(drawing.units) + '版本:' + esc(t.revision || 'A') + ''); + lines.push(''); return lines.join(''); +} + +export function drawingToJSON(drawing) { return clone(drawing); } +export function drawingFromJSON(data) { return { ...createDrawing(data || {}), ...clone(data || {}), views: clone(data?.views || []), dimensions: clone(data?.dimensions || []), notes: clone(data?.notes || []) }; } diff --git a/js/features/fillet.js b/js/features/fillet.js new file mode 100644 index 0000000..afcf066 --- /dev/null +++ b/js/features/fillet.js @@ -0,0 +1,163 @@ +// 受限的实体圆角:只处理方向一致的闭合凸 MeshData 上的一条直棱。 +// 通过连续的切平面构造圆柱面分段逼近;成功时返回的新 MeshData 所有权交给调用方。 +import { extractTopology, resolveEdgeRef } from './chamfer.js'; + +const EPS = 1e-7; +const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +const mul = (a, s) => [a[0] * s, a[1] * s, a[2] * s]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const len = a => Math.hypot(a[0], a[1], a[2]); +const unit = a => { const l = len(a); return l > EPS ? mul(a, 1 / l) : null; }; +const at = (verts, i) => [verts[i * 3], verts[i * 3 + 1], verts[i * 3 + 2]]; +const key = (p, tol) => p.map(v => Math.round(v / tol)).join(','); + +function triNormal(mesh, offset) { + const a = at(mesh.verts, mesh.tris[offset]), b = at(mesh.verts, mesh.tris[offset + 1]), c = at(mesh.verts, mesh.tris[offset + 2]); + const n = unit(cross(sub(b, a), sub(c, a))); + if (!n) throw new Error('网格含退化三角形,无法创建圆角'); + return n; +} + +function assertConvex(mesh, tolerance) { + // extractTopology 同时检查闭合、朝向和正体积;这里补上严格凸性。 + extractTopology(mesh, tolerance); + const count = mesh.verts.length / 3; + for (let ti = 0; ti < mesh.tris.length; ti += 3) { + const a = at(mesh.verts, mesh.tris[ti]), n = triNormal(mesh, ti); + for (let i = 0; i < count; i++) { + if (dot(n, sub(at(mesh.verts, i), a)) > tolerance * 8) { + throw new Error('当前圆角只支持闭合凸实体;检测到凹边或非凸拓扑'); + } + } + } +} + +function availableFaceWidth(mesh, edge, normal) { + let available = 0; + for (let i = 0; i < mesh.verts.length / 3; i++) { + const p = at(mesh.verts, i); + if (Math.abs(dot(normal, sub(p, edge.a))) > 1e-5) continue; + const along = dot(sub(p, edge.a), edge.direction); + const off = sub(sub(p, edge.a), mul(edge.direction, along)); + available = Math.max(available, len(off)); + } + return available; +} + +function addTriangle(list, a, b, c, tolerance) { + if (len(cross(sub(b, a), sub(c, a))) > tolerance * tolerance) list.push(a, b, c); +} + +function dedupe(points, tolerance) { + const seen = new Map(); + for (const p of points) seen.set(key(p, tolerance), p); + return [...seen.values()]; +} + +function clipPolygon(points, planePoint, normal, tolerance, cutPoints) { + // 保留 dot(normal, p-planePoint) <= 0 一侧;normal 是新圆角面的外法向。 + const out = []; + const value = p => dot(normal, sub(p, planePoint)); + for (let i = 0; i < points.length; i++) { + const a = points[i], b = points[(i + 1) % points.length]; + const va = value(a), vb = value(b); + const inA = va <= tolerance, inB = vb <= tolerance; + if (inA) out.push(a); + if (inA !== inB) { + const t = va / (va - vb); + const p = add(a, mul(sub(b, a), t)); + out.push(p); cutPoints.push(p); + } + } + for (const p of out) if (Math.abs(value(p)) <= tolerance * 4) cutPoints.push(p); + return out; +} + +function capLoop(points, normal, tolerance, triangles) { + const loop = dedupe(points, tolerance); + if (loop.length < 3) throw new Error('圆角截面退化,请减小半径'); + const center = loop.reduce((s, p) => add(s, p), [0, 0, 0]).map(v => v / loop.length); + let u = unit(sub(loop[0], center)); + if (!u) u = unit(sub(loop[1], center)); + const v = u && unit(cross(normal, u)); + if (!u || !v) throw new Error('圆角截面退化,请减小半径'); + loop.sort((a, b) => Math.atan2(dot(sub(a, center), v), dot(sub(a, center), u)) - Math.atan2(dot(sub(b, center), v), dot(sub(b, center), u))); + // u × (normal × u) 指向 normal,故此绕序直接是外向。 + for (let i = 0; i < loop.length; i++) addTriangle(triangles, center, loop[i], loop[(i + 1) % loop.length], tolerance); +} + +function indexMesh(triangles, tolerance) { + const verts = [], tris = [], byKey = new Map(); + const indexOf = p => { + const k = key(p, tolerance); + const old = byKey.get(k); + if (old !== undefined) return old; + const i = verts.length / 3; + verts.push(p[0], p[1], p[2]); byKey.set(k, i); + return i; + }; + for (let i = 0; i < triangles.length; i += 3) { + const a = indexOf(triangles[i]), b = indexOf(triangles[i + 1]), c = indexOf(triangles[i + 2]); + if (a !== b && b !== c && c !== a) tris.push(a, b, c); + } + // Float32 落盘后,接近共线的小扇片可能在舍入时退化;在最终精度上再过滤一次。 + const finalVerts = new Float32Array(verts), finalTris = []; + for (let i = 0; i < tris.length; i += 3) { + const a = at(finalVerts, tris[i]), b = at(finalVerts, tris[i + 1]), c = at(finalVerts, tris[i + 2]); + if (len(cross(sub(b, a), sub(c, a))) > Math.max(tolerance * tolerance * 4, EPS * 2)) finalTris.push(tris[i], tris[i + 1], tris[i + 2]); + } + return { verts: finalVerts, tris: new Uint32Array(finalTris) }; +} + +function cutConvexMesh(mesh, planePoint, normal, tolerance) { + const triangles = [], cuts = []; + for (let ti = 0; ti < mesh.tris.length; ti += 3) { + const poly = clipPolygon([at(mesh.verts, mesh.tris[ti]), at(mesh.verts, mesh.tris[ti + 1]), at(mesh.verts, mesh.tris[ti + 2])], planePoint, normal, tolerance, cuts); + for (let i = 1; i < poly.length - 1; i++) addTriangle(triangles, poly[0], poly[i], poly[i + 1], tolerance); + } + if (cuts.length < 3) throw new Error('圆角半径或目标棱无效,未形成可封闭截面'); + capLoop(cuts, normal, tolerance, triangles); + return indexMesh(triangles, tolerance); +} + +function arcNormal(n1, n2, fraction) { + const cosine = Math.max(-1, Math.min(1, dot(n1, n2))); + const angle = Math.acos(cosine); + const sine = Math.sin(angle); + if (sine < 1e-6) throw new Error('相邻面夹角退化,无法创建圆角'); + return unit(add(mul(n1, Math.sin((1 - fraction) * angle) / sine), mul(n2, Math.sin(fraction * angle) / sine))); +} + +/** + * 创建一条凸直棱的恒定半径圆角。圆柱面由 segments 个相切平面构成, + * 因而导出的 STL 保留真实、多法向的 R 过渡面(不是光照平滑)。 + */ +export function buildFilletMesh({ mesh, edgeRefs, radius, segments = 8, tolerance = 1e-6 } = {}) { + if (!(Number.isFinite(tolerance) && tolerance > 0)) throw new Error('圆角容差必须为正数'); + if (!Array.isArray(edgeRefs) || edgeRefs.length !== 1) throw new Error('当前圆角仅支持选择一条凸直棱边'); + if (!(Number.isFinite(radius) && radius > tolerance)) throw new Error('圆角半径必须为大于容差的正数'); + if (!Number.isInteger(segments) || segments < 2 || segments > 48) throw new Error('圆角分段数必须是 2 到 48 的整数'); + + assertConvex(mesh, tolerance); + const topology = extractTopology(mesh, tolerance); + const edge = edgeRefs[0] && edgeRefs[0].a ? edgeRefs[0] : resolveEdgeRef(edgeRefs[0], topology); + const [n1, n2] = edge.normals; + const cosine = dot(n1, n2); + if (cosine <= -0.999 || cosine >= 0.999999) throw new Error('相邻面夹角退化,无法创建圆角'); + const width = Math.min(availableFaceWidth(mesh, edge, n1), availableFaceWidth(mesh, edge, n2)); + if (!(width > tolerance) || radius >= width - tolerance) throw new Error('圆角半径过大,超过相邻面的可用宽度'); + + // q 是从原棱到圆心线的位移。其到两张原面的有符号距离均为 -radius。 + const centerShift = mul(add(n1, n2), -radius / (1 + cosine)); + let out = mesh; + for (let i = 0; i < segments; i++) { + const normal = arcNormal(n1, n2, (i + 0.5) / segments); + const planePoint = add(add(edge.a, centerShift), mul(normal, radius)); + out = cutConvexMesh(out, planePoint, normal, tolerance); + } + // 每一步都保持闭合;末次再检查能在 MeshData 层提前给出明确诊断。 + extractTopology(out, tolerance); + return { ...out, selectedEdges: [{ start: edge.a.slice(), end: edge.b.slice() }], radius, segments }; +} diff --git a/js/features/loft.js b/js/features/loft.js new file mode 100644 index 0000000..c715e4d --- /dev/null +++ b/js/features/loft.js @@ -0,0 +1,182 @@ +/** + * 放样的纯 MeshData 构建器。输入中的数组均使用 mm;返回值所有权交给调用方。 + * 不创建 Manifold/WASM 对象,因此没有 delete 生命周期要求。 + */ + +const EPS = 1e-6; + +const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +const scale = (a, n) => [a[0] * n, a[1] * n, a[2] * n]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const len = a => Math.hypot(a[0], a[1], a[2]); +const norm = a => { const n = len(a); if (n < EPS) throw new Error('放样截面法向量退化'); return scale(a, 1 / n); }; +const centroid = pts => scale(pts.reduce((s, p) => add(s, p), [0, 0, 0]), 1 / pts.length); + +function newell(points) { + const n = [0, 0, 0]; + for (let i = 0; i < points.length; i++) { + const a = points[i], b = points[(i + 1) % points.length]; + n[0] += (a[1] - b[1]) * (a[2] + b[2]); + n[1] += (a[2] - b[2]) * (a[0] + b[0]); + n[2] += (a[0] - b[0]) * (a[1] + b[1]); + } + return n; +} + +function profilePoints(profile) { + const raw = Array.isArray(profile) ? profile : profile?.points; + if (!Array.isArray(raw) || raw.length < 3) throw new Error('放样截面至少需要三个顶点'); + const plane = !Array.isArray(profile) ? profile?.plane : null; + const out = raw.map((p) => { + if (!Array.isArray(p) || p.some(v => !Number.isFinite(Number(v)))) throw new Error('放样截面包含无效坐标'); + if (p.length >= 3) return [Number(p[0]), Number(p[1]), Number(p[2])]; + if (p.length === 2 && plane?.origin && plane?.xAxis && plane?.yAxis) { + return add(add(plane.origin, scale(plane.xAxis, Number(p[0]))), scale(plane.yAxis, Number(p[1]))); + } + throw new Error('二维截面必须提供 plane.origin、xAxis 和 yAxis'); + }); + if (len(sub(out[0], out[out.length - 1])) < EPS) out.pop(); + if (out.length < 3) throw new Error('放样截面退化,至少需要三个不同顶点'); + return out; +} + +function basis(points) { + let rawNormal = newell(points); + // 交叉的平面多边形可能 Newell 法向量相消;仍先建立投影基以给出“自交”诊断。 + if (len(rawNormal) < EPS) { + const origin0 = points[0]; + for (let i = 1; i < points.length - 1 && len(rawNormal) < EPS; i++) rawNormal = cross(sub(points[i], origin0), sub(points[i + 1], origin0)); + } + const n = norm(rawNormal); + const origin = points[0]; + let u = sub(points[1], origin); + u = norm(sub(u, scale(n, dot(u, n)))); + return { origin, u, v: cross(n, u), n }; +} + +function as2d(points, b) { return points.map(p => { const q = sub(p, b.origin); return [dot(q, b.u), dot(q, b.v)]; }); } +function area2d(points) { return points.reduce((s, p, i) => { const q = points[(i + 1) % points.length]; return s + p[0] * q[1] - q[0] * p[1]; }, 0) / 2; } +function orient(a, b, c) { return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); } +function onSegment(a, b, p) { return Math.min(a[0], b[0]) - EPS <= p[0] && p[0] <= Math.max(a[0], b[0]) + EPS && Math.min(a[1], b[1]) - EPS <= p[1] && p[1] <= Math.max(a[1], b[1]) + EPS; } +function intersects(a, b, c, d) { + const abC = orient(a, b, c), abD = orient(a, b, d), cdA = orient(c, d, a), cdB = orient(c, d, b); + if (Math.abs(abC) < EPS && onSegment(a, b, c)) return true; + if (Math.abs(abD) < EPS && onSegment(a, b, d)) return true; + if (Math.abs(cdA) < EPS && onSegment(c, d, a)) return true; + if (Math.abs(cdB) < EPS && onSegment(c, d, b)) return true; + return (abC > 0) !== (abD > 0) && (cdA > 0) !== (cdB > 0); +} +function validatePolygon(points) { + const b = basis(points), p = as2d(points, b); + for (const point of points) { + if (Math.abs(dot(sub(point, b.origin), b.n)) > 1e-5) throw new Error('放样截面必须共面'); + } + for (let i = 0; i < p.length; i++) for (let j = i + 1; j < p.length; j++) { + if (i === j || (i + 1) % p.length === j || (j + 1) % p.length === i) continue; + if (intersects(p[i], p[(i + 1) % p.length], p[j], p[(j + 1) % p.length])) throw new Error('放样截面存在自交'); + } + if (Math.abs(area2d(p)) < EPS) throw new Error('放样截面面积趋近于零'); + return b; +} + +/** 按闭合轮廓的均匀弧长重采样;保留输入点的三维坐标。 */ +export function resampleClosedProfile(points, count) { + if (!Number.isInteger(count) || count < 3) throw new Error('重采样顶点数至少为 3'); + const src = profilePoints(points); + validatePolygon(src); + const lengths = [0]; + for (let i = 0; i < src.length; i++) lengths.push(lengths[i] + len(sub(src[(i + 1) % src.length], src[i]))); + const total = lengths[lengths.length - 1]; + if (total < EPS) throw new Error('放样截面周长趋近于零'); + const out = []; + for (let k = 0; k < count; k++) { + const d = total * k / count; + let i = 0; while (i + 1 < lengths.length && lengths[i + 1] < d - EPS) i++; + const edge = lengths[i + 1] - lengths[i]; + const t = edge < EPS ? 0 : (d - lengths[i]) / edge; + const a = src[i], b = src[(i + 1) % src.length]; + out.push(add(a, scale(sub(b, a), t))); + } + return out; +} + +/** 将 candidate 循环移位到与 reference 的对应距离最小;返回新数组,不修改输入。 */ +export function alignProfileStart(reference, candidate) { + if (!Array.isArray(reference) || !Array.isArray(candidate) || reference.length !== candidate.length) throw new Error('起点对齐要求两个截面顶点数相同'); + let best = null, bestCost = Infinity; + for (const reverse of [false, true]) { + const c = reverse ? candidate.slice().reverse() : candidate; + for (let shift = 0; shift < c.length; shift++) { + let cost = 0; + for (let i = 0; i < c.length; i++) { const d = sub(reference[i], c[(i + shift) % c.length]); cost += dot(d, d); } + if (cost < bestCost) { bestCost = cost; best = c.map((_, i) => c[(i + shift) % c.length].slice()); } + } + } + return best; +} + +function pointInTriangle(p, a, b, c) { + const s1 = orient(a, b, p), s2 = orient(b, c, p), s3 = orient(c, a, p); + return s1 >= -EPS && s2 >= -EPS && s3 >= -EPS; +} +function triangulate(points) { + const p = as2d(points, basis(points)); + const ccw = area2d(p) > 0; + const idx = Array.from({ length: p.length }, (_, i) => i); + const tris = []; + while (idx.length > 3) { + let clipped = false; + for (let k = 0; k < idx.length; k++) { + const a = idx[(k - 1 + idx.length) % idx.length], b = idx[k], c = idx[(k + 1) % idx.length]; + if ((orient(p[a], p[b], p[c]) > EPS) !== ccw) continue; + if (idx.some(x => x !== a && x !== b && x !== c && pointInTriangle(p[x], p[a], p[b], p[c]))) continue; + tris.push(a, b, c); idx.splice(k, 1); clipped = true; break; + } + if (!clipped) throw new Error('放样截面无法三角剖分'); + } + tris.push(idx[0], idx[1], idx[2]); + return tris; +} + +/** + * 构建封闭放样 MeshData。profiles 可为 ProfileData(二维点 + plane)或三维点数组。 + * alignment 默认为 'auto';传 'none' 保留各截面的起点。capStart/capEnd 默认 true。 + */ +export function buildLoftMesh({ profiles, resampleCount, capStart = true, capEnd = true, alignment = 'auto' } = {}) { + if (!Array.isArray(profiles) || profiles.length < 2) throw new Error('放样至少需要两个截面'); + let rings = profiles.map(profilePoints); + const counts = rings.map(r => r.length); + const count = resampleCount == null ? Math.max(...counts) : Number(resampleCount); + if (!Number.isInteger(count) || count < 3 || count > 2048) throw new Error('放样重采样顶点数必须在 3 到 2048 之间'); + rings = rings.map(r => r.length === count ? r.map(p => p.slice()) : resampleClosedProfile(r, count)); + const bases = rings.map(validatePolygon); + const centers = rings.map(centroid); + const travel = sub(centers[1], centers[0]); + if (len(travel) < EPS) throw new Error('相邻放样截面共面或重复'); + const direction = norm(travel); + for (let i = 0; i < rings.length; i++) { + const b = bases[i]; + if (1 - Math.abs(dot(b.n, bases[0].n)) > 1e-5) throw new Error('放样截面必须彼此平行'); + if (Math.abs(dot(b.n, direction)) < 1e-4) throw new Error('放样截面必须彼此平行'); + if (i && dot(sub(centers[i], centers[i - 1]), direction) < EPS) throw new Error('放样截面顺序错误或存在共面重复截面'); + if (dot(b.n, direction) < 0) rings[i].reverse(); + if (i && alignment !== 'none') rings[i] = alignProfileStart(rings[i - 1], rings[i]); + } + // 避免截面之间的侧壁整体反向/折返。完全的曲面自交需要更重的三角形相交算法,先诊断常见翻转。 + for (let i = 0; i < rings.length - 1; i++) for (let j = 0; j < count; j++) { + const a = rings[i][j], b = rings[i][(j + 1) % count], c = rings[i + 1][j]; + if (len(cross(sub(b, a), sub(c, a))) < EPS) throw new Error('放样生成体存在退化或翻转侧壁'); + } + const verts = new Float32Array(rings.length * count * 3); + rings.forEach((ring, i) => ring.forEach((p, j) => { const k = (i * count + j) * 3; verts[k] = p[0]; verts[k + 1] = p[1]; verts[k + 2] = p[2]; })); + const tris = []; + for (let i = 0; i < rings.length - 1; i++) for (let j = 0; j < count; j++) { + const a = i * count + j, b = i * count + (j + 1) % count, c = (i + 1) * count + j, d = (i + 1) * count + (j + 1) % count; + tris.push(a, b, d, a, d, c); + } + if (capStart) { const cap = triangulate(rings[0]); for (let i = 0; i < cap.length; i += 3) tris.push(cap[i + 2], cap[i + 1], cap[i]); } + if (capEnd) { const cap = triangulate(rings[rings.length - 1]); const base = (rings.length - 1) * count; for (let i = 0; i < cap.length; i += 3) tris.push(base + cap[i], base + cap[i + 1], base + cap[i + 2]); } + return { verts, tris: new Uint32Array(tris) }; +} diff --git a/js/features/reference.js b/js/features/reference.js new file mode 100644 index 0000000..78c8128 --- /dev/null +++ b/js/features/reference.js @@ -0,0 +1,162 @@ +// M06 参考几何:只处理可 JSON 序列化的普通数据,不持有文档或 WASM 对象。 +const EPS = 1e-9; + +const isVec3 = v => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite); +const copy3 = v => [v[0], v[1], v[2]]; +const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const mul = (v, n) => [v[0] * n, v[1] * n, v[2] * n]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const length = v => Math.sqrt(dot(v, v)); + +function vector(value, label) { + if (!isVec3(value)) throw new Error(`${label}必须是三个有限数值组成的向量`); + return copy3(value); +} + +function unit(value, label) { + const v = vector(value, label), n = length(v); + if (n < EPS) throw new Error(`${label}不能是零向量`); + return mul(v, 1 / n); +} + +function pointOf(value, label = '点') { + if (isVec3(value)) return copy3(value); + if (value && isVec3(value.position)) return copy3(value.position); + if (value && isVec3(value.origin)) return copy3(value.origin); + throw new Error(`${label}缺少有效位置`); +} + +function sourceIds(...values) { + return values.map(v => v && v.id).filter(v => v !== undefined && v !== null); +} + +function planeBasis(plane) { + if (!plane || plane.type !== 'plane') throw new Error('需要有效的基准面'); + const normal = unit(plane.normal, '基准面法向'); + let xAxis = plane.xAxis ? vector(plane.xAxis, '基准面x轴') : [1, 0, 0]; + xAxis = sub(xAxis, mul(normal, dot(xAxis, normal))); + if (length(xAxis) < EPS) { + const fallback = Math.abs(normal[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + xAxis = cross(fallback, normal); + } + return { origin: pointOf(plane, '基准面原点'), normal, xAxis: unit(xAxis, '基准面x轴') }; +} + +function axisData(axis) { + if (!axis || axis.type !== 'axis') throw new Error('需要有效的基准轴'); + return { origin: pointOf(axis, '基准轴原点'), direction: unit(axis.direction, '基准轴方向') }; +} + +function rotateAroundAxis(point, axis, radians) { + const p = sub(point, axis.origin), k = axis.direction; + const c = Math.cos(radians), s = Math.sin(radians); + return add(axis.origin, add(add(mul(p, c), mul(cross(k, p), s)), mul(k, dot(k, p) * (1 - c)))); +} + +function rotateVector(value, axis, radians) { + return sub(rotateAroundAxis(value, { origin: [0, 0, 0], direction: axis.direction }, radians), [0, 0, 0]); +} + +/** 沿基准面法向偏移,距离单位为 mm。 */ +export function makeOffsetPlane(basePlane, distance) { + const base = planeBasis(basePlane); + if (!Number.isFinite(distance)) throw new Error('偏移距离必须是有限数值'); + return { type: 'plane', origin: add(base.origin, mul(base.normal, distance)), normal: base.normal, xAxis: base.xAxis, + sourceRefs: sourceIds(basePlane), params: { kind: 'offset', distance } }; +} + +/** 绕给定轴旋转基准面,角度对外使用度。 */ +export function makeAngledPlane(basePlane, axis, degrees) { + const base = planeBasis(basePlane), a = axisData(axis); + if (!Number.isFinite(degrees)) throw new Error('角度必须是有限数值'); + const radians = degrees * Math.PI / 180; + return { type: 'plane', origin: rotateAroundAxis(base.origin, a, radians), normal: unit(rotateVector(base.normal, a, radians), '旋转后法向'), xAxis: unit(rotateVector(base.xAxis, a, radians), '旋转后x轴'), + sourceRefs: sourceIds(basePlane, axis), params: { kind: 'angled', degrees } }; +} + +/** 创建与基准面平行、并经过指定点的基准面。 */ +export function makePlaneThroughPoint(basePlane, point) { + const base = planeBasis(basePlane), origin = pointOf(point); + return { type: 'plane', origin, normal: base.normal, xAxis: base.xAxis, + sourceRefs: sourceIds(basePlane, point), params: { kind: 'throughPoint' } }; +} + +/** 用两点建立方向从 a 指向 b 的基准轴。 */ +export function makeAxisFromPoints(a, b) { + const origin = pointOf(a, '第一点'), end = pointOf(b, '第二点'); + const delta = sub(end, origin); + if (length(delta) < EPS) throw new Error('两点重合,不能建立基准轴'); + return { type: 'axis', origin, direction: unit(delta, '两点基准轴方向'), sourceRefs: sourceIds(a, b), params: { kind: 'twoPoints' } }; +} + +/** 用两个不平行基准面的交线建立基准轴。 */ +export function makeAxisFromPlanes(a, b) { + const pa = planeBasis(a), pb = planeBasis(b), direction = cross(pa.normal, pb.normal); + const d2 = dot(direction, direction); + if (d2 < EPS) throw new Error('两个平面平行或重合,不能建立交线基准轴'); + const ca = dot(pa.normal, pa.origin), cb = dot(pb.normal, pb.origin); + const origin = mul(cross(sub(mul(pb.normal, ca), mul(pa.normal, cb)), direction), 1 / d2); + return { type: 'axis', origin, direction: unit(direction, '平面交线方向'), sourceRefs: sourceIds(a, b), params: { kind: 'planeIntersection' } }; +} + +/** 由原点、x 方向提示和 y 方向提示创建正交右手坐标系。 */ +export function makeCoordinateSystem(origin, xHint, yHint) { + const o = pointOf(origin, '坐标系原点'), xAxis = unit(xHint, '坐标系x方向'); + const yProjected = sub(vector(yHint, '坐标系y方向'), mul(xAxis, dot(yHint, xAxis))); + if (length(yProjected) < EPS) throw new Error('坐标系x轴与y方向不能共线'); + const yAxis = unit(yProjected, '坐标系y轴'), zAxis = unit(cross(xAxis, yAxis), '坐标系z轴'); + return { type: 'csys', origin: o, xAxis, yAxis, zAxis, sourceRefs: sourceIds(origin), params: { kind: 'rightHanded' } }; +} + +function lookupById(lookup, id) { + if (typeof lookup === 'function') return lookup(id); + if (lookup instanceof Map) return lookup.get(id); + if (Array.isArray(lookup)) return lookup.find(item => item && item.id === id); + return lookup && lookup[id]; +} + +/** + * 解析可序列化参考对象。约定:params.kind 为 offset/angled/throughPoint/ + * twoPoints/planeIntersection/entityCenter/rightHanded;sourceRefs 按该 kind + * 的参数顺序保存 ID。lookup 可以是函数、Map、数组或 ID 键对象。 + */ +export function resolveReference(reference, lookup) { + const resolving = new Set(); + const resolve = value => { + const ref = typeof value === 'string' || typeof value === 'number' ? lookupById(lookup, value) : value; + if (!ref) throw new Error(`引用不存在:${String(value)}`); + const key = ref.id ?? ref; + if (resolving.has(key)) throw new Error(`检测到循环引用:${String(ref.id ?? '匿名参考')}`); + resolving.add(key); + try { + const params = ref.params || {}, kind = params.kind; + // 实体中心是一个文档实体引用,而非另一种 ReferenceData;不能把它当作 + // 参考几何递归解析,否则会错误要求实体也带 type/sourceRefs。 + const sources = kind === 'entityCenter' + ? (ref.sourceRefs || []).map(id => lookupById(lookup, id)) + : (ref.sourceRefs || []).map(id => resolve(id)); + let result; + if (kind === 'offset') result = makeOffsetPlane(sources[0], params.distance); + else if (kind === 'angled') result = makeAngledPlane(sources[0], sources[1], params.degrees); + else if (kind === 'throughPoint') result = makePlaneThroughPoint(sources[0], sources[1]); + else if (kind === 'twoPoints') result = makeAxisFromPoints(sources[0], sources[1]); + else if (kind === 'planeIntersection') result = makeAxisFromPlanes(sources[0], sources[1]); + else if (kind === 'entityCenter') { + const entity = sources[0]; + const position = entity && (entity.center || entity.position || (entity.bbox && entity.bbox.min && entity.bbox.max && mul(add(entity.bbox.min, entity.bbox.max), .5))); + result = { type: 'point', position: pointOf(position, '引用实体中心'), sourceRefs: sourceIds(entity), params: { kind } }; + } else if (kind === 'rightHanded') result = makeCoordinateSystem(sources[0] || ref.origin, params.xHint || ref.xAxis, params.yHint || ref.yAxis); + else if (ref.type === 'plane') { const p = planeBasis(ref); result = { type: 'plane', ...p, sourceRefs: ref.sourceRefs || [], params }; } + else if (ref.type === 'axis') { const a = axisData(ref); result = { type: 'axis', ...a, sourceRefs: ref.sourceRefs || [], params }; } + else if (ref.type === 'point') result = { type: 'point', position: pointOf(ref), sourceRefs: ref.sourceRefs || [], params }; + else if (ref.type === 'csys') result = makeCoordinateSystem(ref.origin, ref.xAxis, ref.yAxis); + else throw new Error('未知的参考几何类型'); + if (ref.id !== undefined) result.id = ref.id; + if (ref.name !== undefined) result.name = ref.name; + return result; + } finally { resolving.delete(key); } + }; + return resolve(reference); +} diff --git a/js/features/rib.js b/js/features/rib.js new file mode 100644 index 0000000..0467551 --- /dev/null +++ b/js/features/rib.js @@ -0,0 +1,209 @@ +// 筋特征:纯几何轮廓 + 由调用者注入的 Manifold 内核能力。 +// 本模块绝不初始化 WASM。buildRibTool 返回的 Manifold 所有权交给调用者; +// 用于接触检测的临时交集在本模块内释放。 +import { tessellate } from '../sketch.js'; +import { PLANES } from '../math.js'; + +const EPS = 1e-6; +const sub2 = (a, b) => [a[0] - b[0], a[1] - b[1]]; +const add2 = (a, b) => [a[0] + b[0], a[1] + b[1]]; +const mul2 = (a, s) => [a[0] * s, a[1] * s]; +const len2 = a => Math.hypot(a[0], a[1]); +const cross2 = (a, b) => a[0] * b[1] - a[1] * b[0]; +const dot2 = (a, b) => a[0] * b[0] + a[1] * b[1]; +const area2 = points => points.reduce((sum, p, i) => { const q = points[(i + 1) % points.length]; return sum + p[0] * q[1] - p[1] * q[0]; }, 0) / 2; +const unit2 = a => { const n = len2(a); if (n < EPS) throw new Error('筋路径存在零长度线段'); return mul2(a, 1 / n); }; +const vsub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const vadd = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +const vmul = (a, s) => [a[0] * s, a[1] * s, a[2] * s]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const length = a => Math.hypot(a[0], a[1], a[2]); +const unit = a => { const n = length(a); if (n < EPS) throw new Error('筋草图平面法向无效'); return vmul(a, 1 / n); }; + +function cleanPath(points, dimensions) { + if (!Array.isArray(points) || points.length < 2) throw new Error('筋路径至少需要两个点'); + const out = []; + for (const raw of points) { + if (!Array.isArray(raw) || raw.length < dimensions || raw.slice(0, dimensions).some(v => !Number.isFinite(Number(v)))) throw new Error('筋路径包含无效坐标'); + const p = raw.slice(0, dimensions).map(Number); + if (!out.length || len2(dimensions === 2 ? sub2(p, out.at(-1)) : [p[0] - out.at(-1)[0], p[1] - out.at(-1)[1]]) > EPS || (dimensions === 3 && length(vsub(p, out.at(-1))) > EPS)) out.push(p); + } + if (out.length < 2) throw new Error('筋路径退化为一点'); + if (dimensions === 2 && len2(sub2(out[0], out.at(-1))) < EPS) throw new Error('筋路径必须是开放轮廓'); + if (dimensions === 3 && length(vsub(out[0], out.at(-1))) < EPS) throw new Error('筋路径必须是开放轮廓'); + return out; +} + +function lineIntersection(a, da, b, db) { + const den = cross2(da, db); + if (Math.abs(den) < EPS) return null; + const t = cross2(sub2(b, a), db) / den; + return add2(a, mul2(da, t)); +} + +function sideOffsets(path, distance) { + const dirs = []; + for (let i = 0; i < path.length - 1; i++) dirs.push(unit2(sub2(path[i + 1], path[i]))); + const normals = dirs.map(d => [-d[1], d[0]]); + return path.map((p, i) => { + if (i === 0) return add2(p, mul2(normals[0], distance)); + if (i === path.length - 1) return add2(p, mul2(normals.at(-1), distance)); + const hit = lineIntersection(add2(p, mul2(normals[i - 1], distance)), dirs[i - 1], add2(p, mul2(normals[i], distance)), dirs[i]); + if (!hit || len2(sub2(hit, p)) > Math.max(Math.abs(distance) * 6, EPS)) throw new Error('筋轮廓在折角处退化或厚度过大'); + return hit; + }); +} + +function segmentsIntersect(a, b, c, d) { + const o1 = cross2(sub2(b, a), sub2(c, a)), o2 = cross2(sub2(b, a), sub2(d, a)); + const o3 = cross2(sub2(d, c), sub2(a, c)), o4 = cross2(sub2(d, c), sub2(b, c)); + return (o1 * o2 < -EPS && o3 * o4 < -EPS); +} + +function validateSimple(poly) { + for (let i = 0; i < poly.length; i++) { + const a = poly[i], b = poly[(i + 1) % poly.length]; + if (len2(sub2(a, b)) < EPS) throw new Error('筋轮廓退化'); + for (let j = i + 1; j < poly.length; j++) { + if (j === i || (j + 1) % poly.length === i || (i + 1) % poly.length === j) continue; + if (segmentsIntersect(a, b, poly[j], poly[(j + 1) % poly.length])) throw new Error('筋轮廓自交或厚度过大'); + } + } +} + +/** 对 CCW 简单多边形耳切;筋轮廓可能在折角内侧凹陷,不能使用扇形端盖。 */ +function triangulateCap(poly) { + const remaining = Array.from({ length: poly.length }, (_, i) => i), tris = []; + while (remaining.length > 3) { + let ear = -1; + for (let i = 0; i < remaining.length; i++) { + const a = remaining[(i - 1 + remaining.length) % remaining.length], b = remaining[i], c = remaining[(i + 1) % remaining.length]; + if (cross2(sub2(poly[b], poly[a]), sub2(poly[c], poly[b])) <= EPS) continue; + let contains = false; + for (const k of remaining) { + if (k === a || k === b || k === c) continue; + const ab = cross2(sub2(poly[b], poly[a]), sub2(poly[k], poly[a])); + const bc = cross2(sub2(poly[c], poly[b]), sub2(poly[k], poly[b])); + const ca = cross2(sub2(poly[a], poly[c]), sub2(poly[k], poly[c])); + if (ab >= -EPS && bc >= -EPS && ca >= -EPS) { contains = true; break; } + } + if (!contains) { tris.push(a, b, c); ear = i; break; } + } + if (ear < 0) throw new Error('筋轮廓无法三角化(可能自交或退化)'); + remaining.splice(ear, 1); + } + tris.push(remaining[0], remaining[1], remaining[2]); + return tris; +} + +/** + * 将连续开放二维路径偏置为封闭薄壁轮廓。side: both | left | right | single; + * single 等同 left。返回的数组不含重复终点,单位为 mm。 + */ +export function offsetOpenPath(points, thickness, side = 'both') { + const path = cleanPath(points, 2); + const t = Number(thickness); + if (!Number.isFinite(t) || t <= EPS) throw new Error('筋厚度必须大于 0'); + if (!['both', 'left', 'right', 'single'].includes(side)) throw new Error('筋侧向必须是 both、left、right 或 single'); + const leftDistance = side === 'both' ? t / 2 : (side === 'right' ? 0 : t); + const rightDistance = side === 'both' ? -t / 2 : (side === 'right' ? -t : 0); + const left = sideOffsets(path, leftDistance); + const right = sideOffsets(path, rightDistance); + let outline = left.concat(right.slice().reverse()); + validateSimple(outline); + // 后续三角化按 CCW 外环生成;统一方向也让 single/right 的输出稳定。 + if (area2(outline) < 0) outline = outline.reverse(); + return outline; +} + +/** 构建筋的二维闭合轮廓;draftDegrees 以度数输入,仅允许 -45..45 度。 */ +export function buildRibProfile({ path, thickness, side = 'both', draftDegrees = 0 } = {}) { + const draft = Number(draftDegrees); + if (!Number.isFinite(draft) || Math.abs(draft) >= 45) throw new Error('筋拔模角必须在 -45 到 45 度之间'); + return { points: offsetOpenPath(path, thickness, side), closed: true, draftDegrees: draft, side }; +} + +function orderedLocalPath(sketch) { + if (Array.isArray(sketch?.points)) return cleanPath(sketch.points, 2); + if (!sketch || !Array.isArray(sketch.entities)) throw new Error('筋草图或路径无效'); + const parts = sketch.entities.filter(e => !e.construction).map(tessellate); + if (!parts.length) throw new Error('筋草图中没有路径'); + const chain = parts.shift().map(p => p.slice()); + while (parts.length) { + const end = chain.at(-1); let found = -1, reverse = false; + for (let i = 0; i < parts.length; i++) { + if (len2(sub2(parts[i][0], end)) < EPS) { found = i; break; } + if (len2(sub2(parts[i].at(-1), end)) < EPS) { found = i; reverse = true; break; } + } + if (found < 0) throw new Error('筋路径不连续'); + const next = parts.splice(found, 1)[0]; if (reverse) next.reverse(); + chain.push(...next.slice(1)); + } + return cleanPath(chain, 2); +} + +function meshPrism(outline, origin, xAxis, yAxis, normal, extent, flip, draft) { + const n = outline.length, sign = flip ? -1 : 1; + const height = extent * sign; + const centroid = outline.reduce((s, p) => add2(s, p), [0, 0]).map(v => v / n); + const grow = Math.tan(draft * Math.PI / 180) * Math.abs(extent); + const top = outline.map(p => { + const r = sub2(p, centroid), l = len2(r); + return l < EPS ? p : add2(p, mul2(r, grow / l)); + }); + validateSimple(top); + const world = (p, h) => vadd(vadd(vadd(origin, vmul(xAxis, p[0])), vmul(yAxis, p[1])), vmul(normal, h)); + const verts = []; + for (const p of outline) verts.push(...world(p, 0)); + for (const p of top) verts.push(...world(p, height)); + const tris = []; + const bottomCap = triangulateCap(outline), topCap = triangulateCap(top); + for (let i = 0; i < bottomCap.length; i += 3) tris.push(bottomCap[i], bottomCap[i + 2], bottomCap[i + 1]); + for (let i = 0; i < topCap.length; i += 3) tris.push(n + topCap[i], n + topCap[i + 1], n + topCap[i + 2]); + for (let i = 0; i < n; i++) { const j = (i + 1) % n; tris.push(i, j, n + i, j, n + j, n + i); } + if (sign < 0) for (let i = 0; i < tris.length; i += 3) [tris[i + 1], tris[i + 2]] = [tris[i + 2], tris[i + 1]]; + return { verts: new Float32Array(verts), tris: new Uint32Array(tris) }; +} + +function bodyIntersects(ctx, body, tool) { + const intersect = ctx.intersect || (ctx.mod?.Manifold?.intersection ? ((a, b) => ctx.mod.Manifold.intersection([a, b])) : null); + if (!intersect) throw new Error('筋接触校验需要 ctx.intersect 或 ctx.mod.Manifold.intersection'); + const overlap = intersect(body, tool); + try { return overlap && Number(overlap.volume()) > EPS; } finally { overlap?.delete?.(); } +} + +/** + * 用主代理注入的 ctx.manifoldFromMeshData 创建筋工具,不创建第二套 WASM。 + * body 存在时会验证实体交叠;返回 tool 的所有权交给调用者,body 仍归调用者。 + */ +export function buildRibTool(ctx, { sketch, path, thickness, side = 'both', flip = false, extent, body, draftDegrees = 0 } = {}) { + if (!ctx || typeof ctx.manifoldFromMeshData !== 'function') throw new Error('筋工具需要主代理注入 ctx.manifoldFromMeshData'); + const source = path?.points || path || orderedLocalPath(sketch); + const local = cleanPath(source, Array.isArray(source[0]) && source[0].length >= 3 ? 3 : 2); + let local2, origin, xAxis, yAxis, normal; + if (local[0].length === 3) { + origin = local[0]; xAxis = unit(vsub(local[1], origin)); + normal = unit(ctx.planeNormal || sketch?.plane?.normal || [0, 0, 1]); + yAxis = unit(cross(normal, xAxis)); + local2 = local.map(p => [dot(vsub(p, origin), xAxis), dot(vsub(p, origin), yAxis)]); + } else { + const plane = typeof sketch?.plane === 'string' ? PLANES[sketch.plane] : sketch?.plane; + xAxis = unit(plane?.xAxis || plane?.uAxis || [1, 0, 0]); + normal = unit(plane?.normal || [0, 0, 1]); yAxis = unit(plane?.yAxis || plane?.vAxis || cross(normal, xAxis)); + const offset = Number(sketch?.offset || 0), sign = Number(sketch?.s ?? 1); + if (!Number.isFinite(offset) || !Number.isFinite(sign)) throw new Error('筋草图偏移无效'); + origin = vadd(plane?.origin ? plane.origin.slice() : [0, 0, 0], vmul(normal, offset * sign)); + local2 = local; + } + const h = Number(extent ?? 10); + if (!Number.isFinite(h) || h <= EPS) throw new Error('筋拉伸范围必须大于 0'); + const profile = buildRibProfile({ path: local2, thickness, side, draftDegrees }); + const mesh = meshPrism(profile.points, origin, xAxis, yAxis, normal, h, !!flip, profile.draftDegrees); + let tool; + try { + tool = ctx.manifoldFromMeshData(mesh, '筋'); + if (body && !bodyIntersects(ctx, body, tool)) throw new Error('筋不接触主体,无法形成连通实体'); + return tool; + } catch (error) { tool?.delete?.(); throw error; } +} diff --git a/js/features/sweep.js b/js/features/sweep.js new file mode 100644 index 0000000..1be04b8 --- /dev/null +++ b/js/features/sweep.js @@ -0,0 +1,221 @@ +// 通用扫描:纯 MeshData 实现,不持有 WASM 对象;返回值所有权归调用方。 +import { tessellate, findLoops } from '../sketch.js'; +import { toWorld } from '../math.js'; + +const EPS = 1e-6; +const vadd = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +const vsub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const vmul = (a, s) => [a[0] * s, a[1] * s, a[2] * s]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const length = a => Math.hypot(a[0], a[1], a[2]); +const unit = a => { const l = length(a); return l > EPS ? vmul(a, 1 / l) : null; }; +const dist = (a, b) => length(vsub(a, b)); +const finite2 = p => Array.isArray(p) && p.length >= 2 && p.every(Number.isFinite); +const finite3 = p => Array.isArray(p) && p.length >= 3 && p.slice(0, 3).every(Number.isFinite); +const area2 = ring => ring.reduce((s, p, i) => { const q = ring[(i + 1) % ring.length]; return s + p[0] * q[1] - p[1] * q[0]; }, 0) / 2; + +function sketchWorld(u, v, w, plane, offset, sign) { + if (!plane || typeof plane === 'string') return toWorld(u, v, w, plane || 'front', offset, sign); + const origin = plane.origin, xAxis = plane.xAxis; + const normal = unit(plane.normal || []); + const yAxis = unit(plane.yAxis || (normal && xAxis ? cross(normal, xAxis) : [])); + if (!finite3(origin) || !finite3(xAxis) || !normal || !yAxis) throw new Error('草图参考平面标架无效'); + const ww = Number(offset || 0) + Number(sign || 1) * w; + return [ + origin[0] + xAxis[0] * u + yAxis[0] * v + normal[0] * ww, + origin[1] + xAxis[1] * u + yAxis[1] * v + normal[1] * ww, + origin[2] + xAxis[2] * u + yAxis[2] * v + normal[2] * ww, + ]; +} + +function cleanPath(points) { + if (!Array.isArray(points)) throw new Error('扫描路径必须是三维点数组'); + const out = []; + for (const p of points) { + if (!finite3(p)) throw new Error('扫描路径必须由有限的三维坐标组成'); + const q = [Number(p[0]), Number(p[1]), Number(p[2])]; + if (!out.length || dist(q, out[out.length - 1]) > EPS) out.push(q); + } + if (out.length < 2) throw new Error('扫描路径至少需要两个不同的点'); + return out; +} + +function orderedPathParts(entities) { + const parts = entities.filter(e => !e.construction && ['line', 'arc'].includes(e.type)).map(tessellate); + if (!parts.length) throw new Error('路径草图中没有直线或圆弧'); + const chain = parts.shift().map(p => p.slice()); + while (parts.length) { + let found = -1, reverse = false; + let prepend = false; + for (let i = 0; i < parts.length && found < 0; i++) { + const part = parts[i], head = chain[0], tail = chain.at(-1); + if (Math.hypot(tail[0] - part[0][0], tail[1] - part[0][1]) <= EPS) found = i; + else if (Math.hypot(tail[0] - part.at(-1)[0], tail[1] - part.at(-1)[1]) <= EPS) { found = i; reverse = true; } + else if (Math.hypot(head[0] - part.at(-1)[0], head[1] - part.at(-1)[1]) <= EPS) { found = i; prepend = true; } + else if (Math.hypot(head[0] - part[0][0], head[1] - part[0][1]) <= EPS) { found = i; prepend = true; reverse = true; } + } + if (found < 0) throw new Error('路径草图存在断裂,线段和圆弧必须首尾相连'); + const part = parts.splice(found, 1)[0]; + if (reverse) part.reverse(); + if (prepend) chain.unshift(...part.slice(0, -1)); + else chain.push(...part.slice(1)); + } + return chain; +} + +/** 从草图的直线、圆弧生成 PathData。仅接受一条连续开放链。 */ +export function samplePathSketch(sketch, options = {}) { + if (!sketch || !Array.isArray(sketch.entities)) throw new Error('路径草图无效'); + const local = orderedPathParts(sketch.entities); + const plane = options.frame || options.plane || sketch.plane || 'front'; + const offset = Number(options.offset ?? sketch.offset ?? 0); + const sign = Number(options.sign ?? sketch.s ?? 1); + const points = cleanPath(local.map(p => sketchWorld(p[0], p[1], 0, plane, offset, sign))); + if (dist(points[0], points.at(-1)) <= EPS) throw new Error('扫描路径不能闭合'); + return { points, closed: false, sourceSketchId: sketch.id ?? null }; +} + +/** 从封闭草图提取 ProfileData;rings 保留外环和孔环的明确分组。 */ +export function profileFromSketch(sketch, options = {}) { + if (!sketch || !Array.isArray(sketch.entities)) throw new Error('截面草图无效'); + const loops = findLoops(sketch.entities); + if (loops.error || !loops.rings.length) throw new Error(loops.error || '截面草图未找到封闭轮廓'); + const rings = loops.rings.map(r => r.filter((p, i) => i === 0 || Math.hypot(p[0] - r[i - 1][0], p[1] - r[i - 1][1]) > EPS)); + for (const ring of rings) { + if (ring.length < 3 || Math.abs(area2(ring)) < EPS) throw new Error('扫描截面退化,面积必须大于 0'); + } + const plane = options.frame || options.plane || sketch.plane || 'front'; + const offset = Number(options.offset ?? sketch.offset ?? 0), s = Number(options.sign ?? sketch.s ?? 1); + const origin = sketchWorld(0, 0, 0, plane, offset, s); + const x = sketchWorld(1, 0, 0, plane, offset, s).map((value, i) => value - origin[i]); + const y = sketchWorld(0, 1, 0, plane, offset, s).map((value, i) => value - origin[i]); + const xAxis = unit(x), yAxis = unit(y); + return { points: rings[0], rings, closed: true, plane: { origin, xAxis, yAxis, normal: unit(cross(xAxis, yAxis)) }, sourceSketchId: sketch.id ?? null }; +} + +function normalFor(t) { + const candidates = [[0, 0, 1], [0, 1, 0], [1, 0, 0]]; + for (const c of candidates) { const n = unit(vsub(c, vmul(t, dot(c, t)))); if (n) return n; } + throw new Error('无法为扫描路径建立截面方向'); +} +function rotate(v, axis, angle) { + const c = Math.cos(angle), s = Math.sin(angle); + return vadd(vadd(vmul(v, c), vmul(cross(axis, v), s)), vmul(axis, dot(axis, v) * (1 - c))); +} +function pathTangents(path) { + return path.map((p, i) => unit(vsub(i === path.length - 1 ? p : path[i + 1], i === 0 ? p : path[i - 1]))); +} +function makeFrames(path, mode, twistDegrees) { + const tangents = pathTangents(path), frames = []; + let n = normalFor(tangents[0]); + for (let i = 0; i < path.length; i++) { + const t = tangents[i]; + if (i && mode === 'parallelTransport') { + const previous = tangents[i - 1], axis = cross(previous, t), al = length(axis); + if (al > EPS) n = rotate(n, vmul(axis, 1 / al), Math.atan2(al, dot(previous, t))); + n = unit(vsub(n, vmul(t, dot(n, t)))) || normalFor(t); + } else if (mode === 'fixed') n = normalFor(t); + const b = unit(cross(t, n)); + const fraction = path.length === 1 ? 0 : i / (path.length - 1); + const a = Number(twistDegrees || 0) * Math.PI / 180 * fraction; + frames.push({ t, n: rotate(n, t, a), b: rotate(b, t, a) }); + } + return frames; +} + +function validatePath(path, profileRadius) { + for (let i = 0; i < path.length - 1; i++) if (dist(path[i], path[i + 1]) <= EPS) throw new Error('扫描路径存在零长度线段'); + // Non-adjacent segments that meet are a clear self-intersection. Very short + // segments compared with the profile are rejected rather than emitting folded faces. + for (let i = 0; i < path.length - 1; i++) { + if (dist(path[i], path[i + 1]) < profileRadius * 0.2) throw new Error('截面尺寸相对路径过大;请降低采样密度或缩小截面'); + for (let j = i + 2; j < path.length - 1; j++) { + if (dist(path[i], path[j]) < profileRadius * 0.5 || dist(path[i + 1], path[j + 1]) < profileRadius * 0.5) throw new Error('扫描路径急弯或自交,建议降低截面尺寸或平滑路径'); + } + } +} + +function normaliseProfile(profile) { + if (!profile || profile.closed !== true) throw new Error('扫描截面必须是封闭轮廓'); + const source = Array.isArray(profile.rings) ? profile.rings : [profile.points]; + if (!source.length) throw new Error('扫描截面不能为空'); + const rings = source.map(r => { + if (!Array.isArray(r)) throw new Error('扫描截面点格式无效'); + if (!r.every(finite2)) throw new Error('扫描截面必须由有限二维坐标组成'); + const out = r.filter((p, i) => i === 0 || Math.hypot(p[0] - r[i - 1][0], p[1] - r[i - 1][1]) > EPS).map(p => [p[0], p[1]]); + if (out.length > 1 && Math.hypot(out[0][0] - out.at(-1)[0], out[0][1] - out.at(-1)[1]) <= EPS) out.pop(); + if (out.length < 3 || Math.abs(area2(out)) < EPS) throw new Error('扫描截面退化,至少需要三个不共线点'); + return out; + }); + return rings.map((ring, index) => { + const ccw = area2(ring) > 0; + // A positive outer ring and negative hole rings make lateral normals point outward. + return (index === 0 ? ccw : !ccw) ? ring : ring.slice().reverse(); + }); +} + +function pointInTriangle(p, a, b, c) { + const sign = (x, y, z) => (y[0] - x[0]) * (z[1] - x[1]) - (y[1] - x[1]) * (z[0] - x[0]); + const ab = sign(a, b, p), bc = sign(b, c, p), ca = sign(c, a, p); + return ab >= -EPS && bc >= -EPS && ca >= -EPS; +} + +/** Ear clipping for one simple, counter-clockwise polygon; returns local triangle indices. */ +function triangulateSimpleRing(ring) { + const remaining = Array.from({ length: ring.length }, (_, i) => i), out = []; + while (remaining.length > 3) { + let ear = -1; + for (let i = 0; i < remaining.length; i++) { + const a = remaining[(i - 1 + remaining.length) % remaining.length], b = remaining[i], c = remaining[(i + 1) % remaining.length]; + const turn = (ring[b][0] - ring[a][0]) * (ring[c][1] - ring[b][1]) - (ring[b][1] - ring[a][1]) * (ring[c][0] - ring[b][0]); + if (turn <= EPS) continue; + let contains = false; + for (const k of remaining) if (k !== a && k !== b && k !== c && pointInTriangle(ring[k], ring[a], ring[b], ring[c])) { contains = true; break; } + if (!contains) { ear = i; out.push(a, b, c); break; } + } + if (ear < 0) throw new Error('扫描截面不是可封端的简单多边形(可能自交)'); + remaining.splice(ear, 1); + } + out.push(remaining[0], remaining[1], remaining[2]); + return out; +} + +/** + * 构建封闭扫描 MeshData。path 可为 PathData 或点数组;profile 可为 ProfileData。 + * capStart/capEnd 默认 true;frameMode 为 parallelTransport(默认)或 fixed。 + */ +export function buildSweepMesh({ path, profile, frameMode = 'parallelTransport', twistDegrees = 0, capStart = true, capEnd = true } = {}) { + if (!['parallelTransport', 'fixed'].includes(frameMode)) throw new Error('frameMode 必须是 parallelTransport 或 fixed'); + if (!Number.isFinite(Number(twistDegrees))) throw new Error('twistDegrees 必须是有限角度'); + const points = cleanPath(Array.isArray(path) ? path : path && path.points); + const rings = normaliseProfile(profile); + const maxRadius = Math.max(...rings.flat().map(p => Math.hypot(p[0], p[1]))); + validatePath(points, maxRadius); + const frames = makeFrames(points, frameMode, Number(twistDegrees)); + const verts = [], tris = []; + for (const ring of rings) { for (let i = 0; i < points.length; i++) for (const p of ring) { + const f = frames[i], q = vadd(points[i], vadd(vmul(f.n, p[0]), vmul(f.b, p[1]))); verts.push(...q); + }} + let base = 0; + for (const ring of rings) { + const n = ring.length; + for (let i = 0; i < points.length - 1; i++) for (let j = 0; j < n; j++) { + const a = base + i * n + j, b = base + i * n + (j + 1) % n, c = base + (i + 1) * n + j, d = base + (i + 1) * n + (j + 1) % n; + tris.push(a, b, c, b, d, c); + } + base += n * points.length; + } + // Caps intentionally support one outer loop. Multi-loop profiles retain valid side walls; + // callers should leave caps off until the shared triangulation service handles holes. + if (rings.length > 1 && (capStart || capEnd)) throw new Error('多环截面的封端暂不支持;请关闭封端或拆分截面'); + if (rings.length === 1) { + const n = rings[0].length, end = (points.length - 1) * n; + const cap = triangulateSimpleRing(rings[0]); + for (let j = 0; j < cap.length; j += 3) { + if (capStart) tris.push(cap[j], cap[j + 2], cap[j + 1]); + if (capEnd) tris.push(end + cap[j], end + cap[j + 1], end + cap[j + 2]); + } + } + return { verts: new Float32Array(verts), tris: new Uint32Array(tris) }; +} diff --git a/js/features/termination.js b/js/features/termination.js new file mode 100644 index 0000000..577fa0a --- /dev/null +++ b/js/features/termination.js @@ -0,0 +1,170 @@ +// M07 拉伸终止条件:纯数据解析与射线/平面几何辅助。 +// 本模块不初始化几何内核;toBody 所需的构造和裁剪能力由调用方 ctx 注入。 + +const EPS = 1e-9; +const MODES = new Set(['blind', 'throughAll', 'symmetric', 'toNext', 'toFace', 'toBody']); + +const add = (a, b) => [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; +const sub = (a, b) => [a[0] - b[0], a[0 + 1] - b[1], a[2] - b[2]]; +const scale = (v, n) => [v[0] * n, v[1] * n, v[2] * n]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +const magnitude = v => Math.sqrt(dot(v, v)); + +function point(value, label) { + if (!Array.isArray(value) || value.length !== 3 || !value.every(Number.isFinite)) throw new Error(`${label}必须是三个有限数值组成的坐标`); + return [...value]; +} + +function directionOf(value, flip = false) { + const d = point(value, '拉伸方向'), length = magnitude(d); + if (length < EPS) throw new Error('拉伸方向不能为零向量'); + return scale(d, (flip ? -1 : 1) / length); +} + +function meshOf(body) { + const mesh = body && typeof body.getMesh === 'function' ? body.getMesh() : (body && (body.mesh || body)); + const verts = mesh && (mesh.vertProperties || (typeof mesh.verts !== 'function' ? mesh.verts : null)); + const tris = mesh && (mesh.triVerts || (typeof mesh.tris !== 'function' ? mesh.tris : null)); + const stride = Number(mesh?.numProp || 3); + if (!verts || !tris || !Number.isInteger(stride) || stride < 3 || verts.length % stride || tris.length % 3) throw new Error('目标实体缺少有效网格数据'); + return { verts, tris, stride }; +} + +function vertex(verts, index, stride = 3) { + const i = index * stride; + return [verts[i], verts[i + 1], verts[i + 2]]; +} + +// Möller–Trumbore;返回沿已归一化射线的正距离,或 null。 +function rayTriangle(origin, direction, a, b, c, tolerance) { + const edge1 = sub(b, a), edge2 = sub(c, a), p = cross(direction, edge2); + const determinant = dot(edge1, p); + if (Math.abs(determinant) <= tolerance) return null; + const inv = 1 / determinant, tvec = sub(origin, a), u = dot(tvec, p) * inv; + if (u < -tolerance || u > 1 + tolerance) return null; + const q = cross(tvec, edge1), v = dot(direction, q) * inv; + if (v < -tolerance || u + v > 1 + tolerance) return null; + const distance = dot(edge2, q) * inv; + if (distance <= tolerance) return null; + return { distance, normal: cross(edge1, edge2) }; +} + +/** + * 规范化拉伸终止参数。成功时只返回可 JSON 序列化的纯数据;失败时抛出中文 Error。 + * depth 与 offset 的单位均为 mm;flip 会反转最终的拉伸方向。 + */ +export function normalizeTermination(params = {}) { + if (!params || typeof params !== 'object') throw new Error('终止条件必须是对象'); + // 兼容现有拉伸参数中的 throughAll/symmetric 布尔标记,统一输出 mode。 + const mode = params.mode || params.termination || (params.throughAll ? 'throughAll' : (params.symmetric ? 'symmetric' : 'blind')); + if (!MODES.has(mode)) throw new Error(`不支持的终止模式:${String(mode)}`); + const flip = Boolean(params.flip); + const offset = params.offset === undefined ? 0 : params.offset; + if (!Number.isFinite(offset)) throw new Error('终止偏置必须是有限数值'); + const result = { mode, flip, offset }; + if (mode === 'blind' || mode === 'symmetric') { + const depth = params.depth; + if (!Number.isFinite(depth) || depth <= 0) throw new Error(`${mode === 'blind' ? '固定深度' : '对称深度'}必须大于零`); + result.depth = depth; + } + if (mode === 'toFace') { + const targetFaceRef = params.targetFaceRef ?? params.targetFace ?? params.faceRef; + if (targetFaceRef === undefined || targetFaceRef === null) throw new Error('到面终止缺少目标面引用'); + result.targetFaceRef = targetFaceRef; + } + if (mode === 'toBody') { + const targetBodyRef = params.targetBodyRef ?? params.targetBody ?? params.bodyRef; + if (targetBodyRef === undefined || targetBodyRef === null) throw new Error('到实体终止缺少目标实体引用'); + result.targetBodyRef = targetBodyRef; + } + return result; +} + +/** 沿射线求目标网格的首个有效交点。body 可为 MeshData 或带 getMesh() 的内核实体。 */ +export function resolveToNext({ origin, direction, body, tolerance = 1e-6, flip = false }) { + const o = point(origin, '拉伸起点'), d = directionOf(direction, flip); + if (!Number.isFinite(tolerance) || tolerance <= 0) throw new Error('交点容差必须为正的有限数值'); + const { verts, tris, stride } = meshOf(body); + let hit = null; + for (let i = 0; i < tris.length; i += 3) { + const a = vertex(verts, tris[i], stride), b = vertex(verts, tris[i + 1], stride), c = vertex(verts, tris[i + 2], stride); + const candidate = rayTriangle(o, d, a, b, c, tolerance); + if (candidate && (!hit || candidate.distance < hit.depth)) hit = { depth: candidate.distance, point: add(o, scale(d, candidate.distance)), normal: candidate.normal, faceIndex: i / 3 }; + } + if (!hit) throw new Error('拉伸方向上没有与目标实体的有效交点'); + return { ...hit, direction: d }; +} + +function triangleOf(face) { + if (Array.isArray(face?.vertices) && face.vertices.length === 3) return face.vertices.map((v, i) => point(v, `目标三角面顶点${i + 1}`)); + if (Array.isArray(face?.points) && face.points.length === 3) return face.points.map((v, i) => point(v, `目标三角面顶点${i + 1}`)); + return null; +} + +/** + * 精确求到基准平面或单个三角面的深度。offset 沿拉伸方向附加,不能把结果推回起点或反向。 + * targetFace 为 {origin, normal} 平面,或 {vertices:[[x,y,z],...]} 三角面;不会用固定超大深度替代。 + */ +export function resolveToFace({ origin, direction, targetFace, offset = 0, tolerance = 1e-6, flip = false }) { + const o = point(origin, '拉伸起点'), d = directionOf(direction, flip); + if (!targetFace || typeof targetFace !== 'object') throw new Error('目标面引用无效或已丢失'); + if (!Number.isFinite(offset)) throw new Error('到面偏置必须是有限数值'); + if (!Number.isFinite(tolerance) || tolerance <= 0) throw new Error('交点容差必须为正的有限数值'); + const triangle = triangleOf(targetFace); + let planeOrigin, normal, faceIndex = targetFace.faceIndex; + if (triangle) { + planeOrigin = triangle[0]; normal = cross(sub(triangle[1], triangle[0]), sub(triangle[2], triangle[0])); + if (magnitude(normal) <= tolerance) throw new Error('目标三角面退化,无法作为终止面'); + } else { + planeOrigin = point(targetFace.origin, '目标面原点'); + normal = point(targetFace.normal, '目标面法向'); + if (magnitude(normal) <= tolerance) throw new Error('目标面法向不能为零向量'); + } + const denominator = dot(normal, d); + if (Math.abs(denominator) <= tolerance) throw new Error('拉伸方向与目标面平行,无法计算终止深度'); + const exactDepth = dot(normal, sub(planeOrigin, o)) / denominator; + if (exactDepth <= tolerance) throw new Error('目标面位于拉伸方向的反侧或与起点重合'); + const hitPoint = add(o, scale(d, exactDepth)); + if (triangle && !rayTriangle(o, d, triangle[0], triangle[1], triangle[2], tolerance)) throw new Error('射线未命中目标三角面'); + const depth = exactDepth + offset; + if (depth <= tolerance) throw new Error('偏置后的终止深度必须大于零'); + return { depth, exactDepth, offset, point: hitPoint, normal: scale(normal, 1 / magnitude(normal)), faceIndex, direction: d }; +} + +function bboxOf(ctx, body) { + const bbox = typeof ctx.getBoundingBox === 'function' ? ctx.getBoundingBox(body) : (body && typeof body.boundingBox === 'function' ? body.boundingBox() : body?.bbox); + if (!bbox || !Array.isArray(bbox.min) || !Array.isArray(bbox.max)) throw new Error('到实体终止需要目标实体的包围盒'); + return { min: point(bbox.min, '包围盒最小点'), max: point(bbox.max, '包围盒最大点') }; +} + +/** + * 用注入的 ctx 构造足够覆盖目标实体包围盒的工具,再裁剪到目标实体。 + * ctx 必须提供 buildExtrudeTool(profileTool,{direction,extent})(或 buildTool)和 intersect(tool,targetBody)(或 clipToBody)。 + * 返回工具实体所有权交给调用方;临时的未采用工具由此函数在可用时 delete()。 + */ +export function buildToBodyTool(ctx, { profileTool, targetBody, direction, flip = false }) { + if (!ctx || typeof ctx !== 'object') throw new Error('到实体终止缺少几何内核上下文'); + if (!profileTool || !targetBody) throw new Error('到实体终止缺少轮廓工具或目标实体'); + const d = directionOf(direction, flip), bbox = bboxOf(ctx, targetBody); + const corners = []; + for (const x of [bbox.min[0], bbox.max[0]]) for (const y of [bbox.min[1], bbox.max[1]]) for (const z of [bbox.min[2], bbox.max[2]]) corners.push([x, y, z]); + const origin = profileTool.origin ? point(profileTool.origin, '轮廓工具起点') : (typeof ctx.getToolOrigin === 'function' ? point(ctx.getToolOrigin(profileTool), '轮廓工具起点') : null); + if (!origin) throw new Error('到实体终止需要轮廓工具起点'); + const projections = corners.map(c => dot(sub(c, origin), d)); + const extent = Math.max(...projections); + if (extent <= EPS) throw new Error('目标实体位于拉伸方向的反侧'); + const padding = Math.max(1e-6, (Math.max(...projections) - Math.min(...projections)) * 1e-6); + const builder = ctx.buildExtrudeTool || ctx.buildTool; + const clipper = ctx.intersect || ctx.clipToBody; + if (typeof builder !== 'function' || typeof clipper !== 'function') throw new Error('到实体终止的几何内核上下文缺少构造或裁剪能力'); + const longTool = builder.call(ctx, profileTool, { direction: d, extent: extent + padding }); + if (!longTool) throw new Error('到实体终止未能构造拉伸工具'); + try { + const clipped = clipper.call(ctx, longTool, targetBody); + if (!clipped) throw new Error('拉伸工具与目标实体没有有效交集'); + return clipped; + } finally { + if (longTool && typeof longTool.delete === 'function') longTool.delete(); + } +} diff --git a/js/geometry.js b/js/geometry.js index 5958817..a17fd44 100644 --- a/js/geometry.js +++ b/js/geometry.js @@ -3,6 +3,10 @@ import * as THREE from 'three'; import ManifoldFactory from 'manifold-3d'; import { findLoops, validateProfile } from './sketch.js'; import { toWorld, rot2, dist, PLANES } from './math.js'; +import { buildSweepMesh } from './features/sweep.js'; +import { buildLoftMesh } from './features/loft.js'; +import { extractTopology, buildChamferMesh } from './features/chamfer.js'; +import { buildFilletMesh } from './features/fillet.js'; let mod = null; @@ -46,16 +50,33 @@ export function transformVerts(verts, planeKey, offset, dirSign) { const out = new Float32Array(verts.length); const n = verts.length / 3; const s = dirSign === undefined ? 1 : dirSign; - // 变换矩阵行列式:front=s, top=-s, right=-s(见推导) - const det = planeKey === 'front' ? s : -s; + // 内置平面保持原映射;参考平面使用显式正交标架。 + const isFrame = planeKey && typeof planeKey === 'object'; + const det = isFrame ? frameDeterminant(planeKey) * s : (planeKey === 'front' ? s : -s); for (let i = 0; i < n; i++) { const u = verts[i * 3], v = verts[i * 3 + 1], w = verts[i * 3 + 2]; - const p = toWorld(u, v, w, planeKey, offset, s); + const p = isFrame ? toFrameWorld(u, v, w, planeKey, offset, s) : toWorld(u, v, w, planeKey, offset, s); out[i * 3] = p[0]; out[i * 3 + 1] = p[1]; out[i * 3 + 2] = p[2]; } return { verts: out, flip: det < 0 }; } +function toFrameWorld(u, v, w, frame, offset = 0, s = 1) { + const o = frame.origin, x = frame.xAxis, y = frame.yAxis || vecNorm(vecCross(frame.normal, x)), n = frame.normal; + if (![o, x, y, n].every(a => Array.isArray(a) && a.length >= 3)) throw new Error('参考平面标架无效'); + const ww = Number(offset || 0) + s * w; + return [ + o[0] + x[0] * u + y[0] * v + n[0] * ww, + o[1] + x[1] * u + y[1] * v + n[1] * ww, + o[2] + x[2] * u + y[2] * v + n[2] * ww, + ]; +} + +function frameDeterminant(frame) { + const y = frame.yAxis || vecNorm(vecCross(frame.normal, frame.xAxis)); + return vecDot(vecCross(frame.xAxis, y), frame.normal); +} + /** 用变换后的顶点重建 manifold(拓扑不变;镜像变换时翻转三角面朝向) */ export function manifoldFromVerts(mesh, newVerts, flip) { let tris = mesh.triVerts; @@ -86,15 +107,20 @@ export function buildExtrude(sketch, params, bodyBBox) { // throughAll 时对称拉伸,确保贯穿 body 两侧 const sym = throughAll || !!params.symmetric; const cs = new mod.CrossSection(rings, 0); - const tool = cs.extrude(depth, 0, 0, [1, 1], sym); - cs.delete(); + let tool; + try { tool = cs.extrude(depth, 0, 0, [1, 1], sym); } + finally { cs.delete(); } if (tool.status() !== 'NoError') { tool.delete(); throw new Error('拉伸生成非流形实体'); } - const mesh = tool.getMesh(); - const tf = transformVerts(mesh.vertProperties, sketch.plane, sketch.offset, params.flip ? -1 : 1); - tool.delete(); + let mesh, tf; + try { + mesh = tool.getMesh(); + tf = transformVerts(mesh.vertProperties, sketch.plane, sketch.offset, params.flip ? -1 : 1); + } finally { + tool.delete(); + } const out = manifoldFromVerts(mesh, tf.verts, tf.flip); return out; } @@ -131,34 +157,247 @@ export function buildRevolve(sketch, params) { if (hasPos && hasNeg) throw new Error('旋转截面跨越了旋转轴,请把截面放在轴的一侧'); } let tool; + let cs = null; try { - const cs = new mod.CrossSection(rings, 0); + cs = new mod.CrossSection(rings, 0); tool = cs.revolve(96, params.revolveDegrees ?? 360); - cs.delete(); } catch (e) { throw new Error('旋转失败:' + (e.message || e)); + } finally { + cs?.delete?.(); + } + if (tool.status() !== 'NoError') { + tool.delete(); + throw new Error('旋转生成非流形实体'); } - if (tool.status() !== 'NoError') throw new Error('旋转生成非流形实体'); // 逆旋转回原始截面方向 - if (theta !== 0) tool = tool.rotate(0, 0, -theta * 180 / Math.PI); - if (pL[0] !== 0 || pL[1] !== 0) tool = tool.translate(pL[0], pL[1], 0); + if (theta !== 0) tool = transformOwned(tool, current => current.rotate(0, 0, -theta * 180 / Math.PI)); + if (pL[0] !== 0 || pL[1] !== 0) tool = transformOwned(tool, current => current.translate(pL[0], pL[1], 0)); // 转到世界系 - const mesh = tool.getMesh(); - const tf = transformVerts(mesh.vertProperties, sketch.plane, sketch.offset, params.flip ? -1 : 1); - tool.delete(); + let mesh, tf; + try { + mesh = tool.getMesh(); + tf = transformVerts(mesh.vertProperties, sketch.plane, sketch.offset, params.flip ? -1 : 1); + } finally { + tool.delete(); + } return manifoldFromVerts(mesh, tf.verts, tf.flip); } /** 圆柱原语(凸台/切除),世界系,轴线沿 Z */ export function buildCylinder(params, bodyBBox) { const r = params.r || 10; + const r2 = Number.isFinite(Number(params.r2)) ? Number(params.r2) : r; let h = params.h || 20; if (params.through) h = bigDepth(bodyBBox); let tool = mod.Manifold.cylinder(h, r, r, 96, true); // center=true → z 从 -h/2 到 h/2 - if (params.tx || params.ty || params.tz) tool = tool.translate(params.tx || 0, params.ty || 0, params.tz || 0); + if (r2 !== r) { + tool.delete(); + tool = mod.Manifold.cylinder(h, r, r2, 96, true); + } + if (params.axis === 'x') tool = transformOwned(tool, current => current.rotate(0, 90, 0)); + else if (params.axis === 'y') tool = transformOwned(tool, current => current.rotate(-90, 0, 0)); + if (params.tx || params.ty || params.tz) tool = transformOwned(tool, current => current.translate(params.tx || 0, params.ty || 0, params.tz || 0)); return tool; } +function transformOwned(previous, transform) { + try { + const next = transform(previous); + if (previous && previous !== next) previous.delete?.(); + return next; + } catch (error) { + previous?.delete?.(); + throw error; + } +} + +/** + * 圆形截面扫描(世界坐标系)。 + * params.path: [[x,y,z], ...] 路径采样点;params.radius: 截面半径。 + * 采用平行传递标架,避免弯曲路径上 Frenet 标架在低曲率处翻转。 + */ +export function buildSweep(params) { + if (params && params.profile) { + const mesh = buildSweepMesh({ + path: params.path, + profile: params.profile, + frameMode: params.frameMode || 'parallelTransport', + twistDegrees: params.twistDegrees || 0, + capStart: params.capStart !== false, + capEnd: params.capEnd !== false, + }); + return manifoldFromMeshData(mesh, '扫描'); + } + const radius = Number(params.radius); + if (!(radius > 0)) throw new Error('扫描截面半径必须大于 0'); + const raw = Array.isArray(params.path) ? params.path : []; + const path = []; + for (const p of raw) { + if (!Array.isArray(p) || p.length < 3 || p.some(v => !Number.isFinite(Number(v)))) { + throw new Error('扫描路径必须由三维坐标点组成'); + } + const q = [Number(p[0]), Number(p[1]), Number(p[2])]; + if (!path.length || vecLen(vecSub(q, path[path.length - 1])) > 1e-6) path.push(q); + } + if (path.length < 2) throw new Error('扫描路径至少需要两个不同的点'); + + const sides = Math.max(12, Math.min(96, Math.round(params.segments || 32))); + const frames = buildParallelFrames(path); + const verts = new Float32Array((path.length * sides + 2) * 3); + for (let i = 0; i < path.length; i++) { + const { n, b } = frames[i]; + for (let j = 0; j < sides; j++) { + const a = 2 * Math.PI * j / sides; + const c = Math.cos(a) * radius, s = Math.sin(a) * radius; + const k = (i * sides + j) * 3; + verts[k] = path[i][0] + n[0] * c + b[0] * s; + verts[k + 1] = path[i][1] + n[1] * c + b[1] * s; + verts[k + 2] = path[i][2] + n[2] * c + b[2] * s; + } + } + const startCenter = path.length * sides; + const endCenter = startCenter + 1; + for (let k = 0; k < 3; k++) { + verts[startCenter * 3 + k] = path[0][k]; + verts[endCenter * 3 + k] = path[path.length - 1][k]; + } + + const tris = []; + for (let i = 0; i < path.length - 1; i++) { + const a0 = i * sides, a1 = (i + 1) * sides; + for (let j = 0; j < sides; j++) { + const next = (j + 1) % sides; + tris.push(a0 + j, a0 + next, a1 + next, a0 + j, a1 + next, a1 + j); + } + } + for (let j = 0; j < sides; j++) { + const next = (j + 1) % sides; + tris.push(startCenter, next, j); + const last = (path.length - 1) * sides; + tris.push(endCenter, last + j, last + next); + } + + const out = manifoldFromVerts({ triVerts: new Uint32Array(tris) }, verts, false); + if (out.status() !== 'NoError') { + const status = out.status(); + out.delete(); + throw new Error('扫描生成非流形实体:' + status); + } + return out; +} + +/** 多截面放样;返回值所有权交给调用方。 */ +export function buildLoft(profiles, params = {}) { + const mesh = buildLoftMesh({ + profiles, + resampleCount: params.resampleCount, + capStart: params.capStart !== false, + capEnd: params.capEnd !== false, + alignment: params.alignment || 'auto', + }); + return manifoldFromMeshData(mesh, '放样'); +} + +/** 对现有完整实体应用受限凸直棱倒角,返回替换后的完整 Manifold。 */ +export function buildChamferEdge(body, params = {}) { + if (!body) throw new Error('实体倒角需要已有主体'); + const mesh = meshFromManifold(body); + const topology = extractTopology(mesh, params.tolerance || 1e-6); + const refs = (params.edgeRefs || []).map(ref => ref?.type === 'edge-query' ? resolveEdgeQuery(ref, topology) : ref); + const result = buildChamferMesh({ mesh, edgeRefs: refs, distance: Number(params.distance), tolerance: params.tolerance || 1e-6 }); + return manifoldFromMeshData(result, '实体倒角'); +} + +/** 对现有完整实体应用受限凸直棱恒定半径圆角,返回替换后的完整 Manifold。 */ +export function buildFilletEdge(body, params = {}) { + if (!body) throw new Error('实体圆角需要已有主体'); + const mesh = meshFromManifold(body); + const topology = extractTopology(mesh, params.tolerance || 1e-6); + const refs = (params.edgeRefs || []).map(ref => ref?.type === 'edge-query' ? resolveEdgeQuery(ref, topology) : ref); + const result = buildFilletMesh({ + mesh, + edgeRefs: refs, + radius: Number(params.radius), + segments: Number(params.segments ?? 8), + tolerance: params.tolerance || 1e-6, + }); + return manifoldFromMeshData(result, '实体圆角'); +} + +function resolveEdgeQuery(query, topology) { + if (!Array.isArray(query.point) || query.point.length !== 3) throw new Error('AI 棱边查询缺少三维定位点'); + const direction = Array.isArray(query.direction) ? vecNorm(query.direction.map(Number)) : null; + const candidates = topology.edges.map(edge => { + const ab = vecSub(edge.b, edge.a), ap = vecSub(query.point, edge.a); + const t = Math.max(0, Math.min(1, vecDot(ap, ab) / Math.max(vecDot(ab, ab), 1e-12))); + const closest = [edge.a[0] + ab[0] * t, edge.a[1] + ab[1] * t, edge.a[2] + ab[2] * t]; + const directionPenalty = direction ? 1000 * (1 - Math.abs(vecDot(direction, edge.direction))) : 0; + return { edge, score: vecLen(vecSub(query.point, closest)) + directionPenalty }; + }).sort((a, b) => a.score - b.score); + if (!candidates.length || !Number.isFinite(candidates[0].score)) throw new Error('未找到符合 AI 查询的实体棱边'); + if (candidates[0].score > Number(query.maxDistance ?? Infinity)) throw new Error('AI 棱边查询距离目标实体过远'); + if (candidates[1] && Math.abs(candidates[1].score - candidates[0].score) < topology.tolerance * 4) throw new Error('AI 棱边查询结果不唯一'); + return candidates[0].edge; +} + +/** MeshData → Manifold;返回值所有权交给调用方。 */ +export function manifoldFromMeshData(mesh, label = '网格') { + if (!mesh || !(mesh.verts instanceof Float32Array) || !(mesh.tris instanceof Uint32Array)) { + throw new Error(`${label}数据格式无效`); + } + const out = manifoldFromVerts({ triVerts: mesh.tris }, mesh.verts, false); + const status = out.status(); + if (status !== 'NoError') { + out.delete(); + throw new Error(`${label}生成非流形实体:${status}`); + } + return out; +} + +function buildParallelFrames(path) { + const tangents = path.map((p, i) => { + if (i === 0) return vecNorm(vecSub(path[1], p)); + if (i === path.length - 1) return vecNorm(vecSub(p, path[i - 1])); + return vecNorm(vecSub(path[i + 1], path[i - 1])); + }); + const frames = []; + const t0 = tangents[0]; + const ref = Math.abs(t0[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]; + let n = vecNorm(vecCross(ref, t0)); + let b = vecNorm(vecCross(t0, n)); + frames.push({ t: t0, n, b }); + for (let i = 1; i < path.length; i++) { + const t = tangents[i]; + const projected = vecSub(n, vecScale(t, vecDot(n, t))); + if (vecLen(projected) < 1e-7) { + const fallback = Math.abs(t[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]; + n = vecNorm(vecCross(fallback, t)); + } else { + n = vecNorm(projected); + } + b = vecNorm(vecCross(t, n)); + n = vecNorm(vecCross(b, t)); + frames.push({ t, n, b }); + } + return frames; +} + +const vecSub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const vecScale = (a, s) => [a[0] * s, a[1] * s, a[2] * s]; +const vecDot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const vecCross = (a, b) => [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], +]; +const vecLen = a => Math.hypot(a[0], a[1], a[2]); +const vecNorm = a => { + const l = vecLen(a); + if (l < 1e-9) throw new Error('扫描路径存在退化切线'); + return vecScale(a, 1 / l); +}; + // ---------------- 布尔 ---------------- /** body 与 tool 做并/差,返回新 manifold;旧对象由调用方管理 */ export function booleanOp(body, tool, subtract) { diff --git a/js/main.js b/js/main.js index 473d537..4d55b25 100644 --- a/js/main.js +++ b/js/main.js @@ -1,29 +1,35 @@ // main.js —— MiniSW 主入口:装配视口/文档/草图/特征树/AI 智能体 import * as THREE from 'three'; -import { Viewport } from './viewport.js'; -import { Doc } from './doc.js'; +import { Viewport } from './viewport.js?v=20260810-viewport25'; +import { Doc } from './doc.js?v=20260810-doc2'; import { SketchUI } from './sketchui.js'; import { TreeUI } from './tree.js'; import { initGeometry, toBinarySTL } from './geometry.js'; -import { parseNaturalLanguage, getLLMSettings, setLLMSettings, runLLM, validateOps, executeOps, expandOps } from './agent.js'; +import { getLLMSettings, setLLMSettings, runAgentChat, validateOps, validateOpsAgainstDocument, executeOps, inspectDocument, describeInspection } from './agent.js?v=20260810-agentflow37'; import { solveDim, dimValueOf } from './sketch.js'; import { fmt, PLANES } from './math.js'; +import { extractTopology, makeStableEdgeRef } from './features/chamfer.js'; +import { createAssembly, addComponent, addMate, solveMates, checkAllInterferences, setExplode, transformedMesh } from './features/assembly.js?v=20260810-assembly2'; +import { createDrawing, layoutThreeViews, addView, addDimension, addSectionView, renderDrawingSVG } from './features/drawing.js'; // ================= DOM ================= const $ = id => document.getElementById(id); const ribbon = $('ribbon'); const treeEl = $('tree'), propsEl = $('props'); -const viewportEl = $('viewport'), hudEl = $('viewport-hud'); +const viewportEl = $('viewport'), drawingPreview = $('drawing-preview'), hudEl = $('viewport-hud'), emptyState = $('empty-state'); const statusEl = $('statusbar'); const splash = $('splash'), splashMsg = $('splash-msg'), splashBar = $('splash-bar'); const aiPanel = $('ai-panel'), aiChat = $('ai-chat'), aiInput = $('ai-input'), aiSend = $('ai-send'); -const aiModeBadge = $('ai-mode-badge'), aiClose = $('ai-close'), aiSettings = $('ai-settings'), aiClear = $('ai-clear'); +const aiModeBadge = $('ai-mode-badge'), aiGuide = $('ai-guide'), aiClose = $('ai-close'), aiSettings = $('ai-settings'), aiClear = $('ai-clear'); // ================= 全局状态 ================= const doc = new Doc(); let vp = null, ui = null, tree = null; let mode = 'model'; // model | sketch +let workspace = 'part'; // part | assembly | drawing let selFeature = null; // 当前选中特征 id(模型模式) +let selComponent = null; // 当前选中的装配组件 id +let pendingEdgeFeature = null; // 等待用户在视口中拾取实体棱边 const undoStack = [], redoStack = []; const aiHistory = []; // AI 对话历史(用于 LLM 多轮上下文) @@ -49,6 +55,12 @@ const ICONS = { extrude: '', cut: '', revolve: '', + sweep: '', + loft: '', + rib: '', + reference: '', + chamfer: '', + fillet: '', rebuild: '', cursor: '', line: '', @@ -56,6 +68,9 @@ const ICONS = { rect: '', circle: '', arc: '', + ellipse: '', + slot: '', + spline: '', trim: '', dim: '', h: '', @@ -67,6 +82,7 @@ const ICONS = { viewTop: '', viewRight: '', iso: '', + fit: '', ai: '', }; @@ -89,6 +105,8 @@ const TABS = [ { key: 'feature', label: '特征' }, { key: 'sketch', label: '草图' }, { key: 'view', label: '视图' }, + { key: 'assembly', label: '装配' }, + { key: 'drawing', label: '工程图' }, { key: 'ai', label: 'AI 智能体' }, ]; const ribBody = el('div', 'rib-body'); @@ -119,6 +137,14 @@ function buildRibbon() { tbtn('上视草图', 'newsketch:top', 'plane', { title: '在上视基准面上新建草图' }), tbtn('右视草图', 'newsketch:right', 'plane', { title: '在右视基准面上新建草图' }), ])); + rbFeature.appendChild(group([ + tbtn('扫描', 'advanced:sweep', 'sweep', { title: '选择路径草图和封闭截面草图创建扫描' }), + tbtn('放样', 'advanced:loft', 'loft', { title: '选择两个或多个封闭截面草图创建放样' }), + tbtn('筋', 'advanced:rib', 'rib', { title: '选择开放路径草图,在已有主体上创建加强筋' }), + tbtn('实体倒角', 'edge:chamfer', 'chamfer', { title: '选择一条凸直棱并设置倒角距离' }), + tbtn('实体圆角', 'edge:fillet', 'fillet', { title: '选择一条凸直棱并设置圆角半径' }), + tbtn('偏移基准面', 'reference:offset', 'reference', { title: '基于内置基准面创建偏移参考面' }), + ])); rbFeature.appendChild(group([ tbtn('拉伸凸台', 'feat:extrude', 'extrude', { title: '把当前草图拉伸成实体(需要先进入草图)' }), tbtn('拉伸切除', 'feat:cut', 'cut', { title: '用当前草图从实体上切除(需要先进入草图)' }), @@ -136,6 +162,9 @@ function buildRibbon() { tbtn('矩形', 'tool:rect', 'rect'), tbtn('圆', 'tool:circle', 'circle'), tbtn('圆弧', 'tool:arc', 'arc'), + tbtn('椭圆', 'tool:ellipse', 'ellipse'), + tbtn('长圆槽', 'tool:slot', 'slot'), + tbtn('样条', 'tool:spline', 'spline'), ])); rbSketch.appendChild(group([ tbtn('裁剪', 'tool:trim', 'trim', { title: '点击删除实体' }), @@ -157,9 +186,30 @@ function buildRibbon() { tbtn('上视', 'view:top', 'viewTop'), tbtn('右视', 'view:right', 'viewRight'), tbtn('等轴测', 'view:iso', 'iso'), + tbtn('适配模型', 'view:fit', 'fit', { title: '按真实工程尺寸将模型完整放入视口' }), ])); ribBody.appendChild(rbView); + const rbAssembly = el('div', 'rib-tabbody', '
装配工作区:插入零件、添加配合、检查干涉并生成爆炸视图
'); + rbAssembly.appendChild(group([ + tbtn('新建装配体', 'assembly:new', 'iso', { primary: true }), + tbtn('插入当前零件', 'assembly:add', 'extrude'), + tbtn('添加同心配合', 'assembly:concentric', 'co'), + tbtn('检查干涉', 'assembly:interference', 'cut'), + tbtn('爆炸视图', 'assembly:explode', 'viewRight'), + ])); + ribBody.appendChild(rbAssembly); + + const rbDrawing = el('div', 'rib-tabbody', '
工程图工作区:生成第一角三视图、尺寸和剖视,并导出 SVG
'); + rbDrawing.appendChild(group([ + tbtn('生成三视图', 'drawing:generate', 'viewFront', { primary: true }), + tbtn('添加尺寸', 'drawing:dimension', 'dim'), + tbtn('添加剖视', 'drawing:section', 'cut'), + tbtn('导出 SVG', 'drawing:export', 'rebuild'), + tbtn('导出 PNG', 'drawing:png', 'rebuild'), + ])); + ribBody.appendChild(rbDrawing); + // AI tab const rbAi = el('div', 'rib-tabbody', '
AI 智能体:用自然语言描述零件,自动完成全流程建模
'); rbAi.appendChild(group([tbtn('打开 AI 面板', 'ai-toggle', 'ai', { primary: true })])); @@ -184,13 +234,40 @@ function switchTab(key) { } const bodies = ribBody.querySelectorAll('.rib-tabbody'); bodies.forEach((b, i) => b.classList.toggle('hidden', TABS[i].key !== key)); + if (key === 'assembly') { + workspace = 'assembly'; + if (doc.assembly?.components?.length) { + vp.updateComponents(doc.assembly.components.map(c => ({ ...c, renderMesh: transformedMesh(c.mesh, c.transform) }))); + vp.fitToModel(); + } + } + else if (key === 'drawing') workspace = 'drawing'; + else if (key === 'feature' || key === 'sketch' || key === 'view' || key === 'ai') { workspace = 'part'; selComponent = null; } + if (typeof propsEl !== 'undefined' && (doc.assembly || doc.drawings.length)) renderProps(); + if (workspace === 'assembly' && doc.assembly) renderAssemblyTree(); + else if (workspace === 'part' && tree) tree.render({ selFeatureId: selFeature, onClickSketch: id => enterSketch(id) }); + updateDrawingPreview(); + if (typeof updateChrome === 'function') { updateChrome(); updateStatus(); } } function handleAct(act) { if (act.startsWith('newsketch:')) { newSketch(act.split(':')[1]); return; } if (act.startsWith('feat:')) { featureFlow(act.split(':')[1]); return; } + if (act.startsWith('advanced:')) { advancedFeatureFlow(act.split(':')[1]); return; } + if (act.startsWith('edge:')) { beginEdgeFeature(act.split(':')[1]); return; } + if (act.startsWith('reference:')) { referenceFlow(act.split(':')[1]); return; } + if (act.startsWith('assembly:')) { handleAssemblyAct(act.slice(9)); return; } + if (act.startsWith('drawing:')) { handleDrawingAct(act.slice(8)); return; } if (act.startsWith('tool:')) { setTool(act.split(':')[1]); return; } - if (act.startsWith('view:')) { vp.setView(act.split(':')[1]); return; } + if (act.startsWith('view:')) { + const view = act.split(':')[1]; + if (view === 'fit') { + if (!doc.bodyMesh) { toast('当前没有可适配的实体', 'err'); return; } + vp.fitToModel(); + setTip('已按真实工程尺寸适配模型'); + } else vp.setView(view); + return; + } switch (act) { case 'rebuild': rebuildAndRefresh(); toast('已重建模型', 'ok'); break; case 'finish-sketch': exitSketch(); break; @@ -198,6 +275,84 @@ function handleAct(act) { } } +function handleAssemblyAct(action) { + workspace = 'assembly'; + switchTab('assembly'); + if (action === 'new') { + if (!doc.bodyMesh) { toast('请先创建一个零件实体,再新建装配体', 'err'); return; } + doc.assembly = createAssembly('课程装配体'); + addComponent(doc.assembly, { id: 'component-1', name: doc.name || '零件1', docId: doc.name, mesh: doc.bodyMesh, fixed: true }); + toast('已新建装配体并插入固定零件', 'ok'); + } else if (action === 'add') { + if (!doc.assembly) { toast('请先新建装配体', 'err'); return; } + if (!doc.bodyMesh) { toast('当前没有可插入的实体', 'err'); return; } + const id = 'component-' + (doc.assembly.components.length + 1); + addComponent(doc.assembly, { id, name: (doc.name || '零件') + id.split('-').pop(), docId: doc.name, mesh: doc.bodyMesh, transform: { position: [40 * doc.assembly.components.length, 0, 0] } }); + toast('已插入当前零件:' + id, 'ok'); + } else if (action === 'concentric') { + if (!doc.assembly || doc.assembly.components.length < 2) { toast('至少需要两个组件才能添加配合', 'err'); return; } + const [a, b] = doc.assembly.components; + addMate(doc.assembly, { type: 'concentric', refA: { componentId: a.id }, refB: { componentId: b.id } }); + solveMates(doc.assembly); toast('已添加同心配合', 'ok'); + } else if (action === 'interference') { + if (!doc.assembly) { toast('请先新建装配体', 'err'); return; } + const hits = checkAllInterferences(doc.assembly); + doc.assembly.lastInterferences = hits; renderProps(); + toast((hits.length ? '检测到 ' + hits.length + ' 处干涉' : '未检测到组件干涉'), hits.length ? 'err' : 'ok'); + } else if (action === 'explode') { + if (!doc.assembly) { toast('请先新建装配体', 'err'); return; } + const vectors = Object.fromEntries(doc.assembly.components.slice(1).map((c, i) => [c.id, [30 * (i + 1), 0, 12 * (i + 1)]])); + setExplode(doc.assembly, { progress: doc.assembly.explode?.active ? 0 : 1, vectors }); + toast(doc.assembly.explode.active ? '已展开装配体' : '已恢复装配位置', 'ok'); + } + renderProps(); updateChrome(); rebuildAndRefresh(); +} + +function handleDrawingAct(action) { + workspace = 'drawing'; + switchTab('drawing'); + if (action === 'generate') { + if (!doc.bodyMesh) { toast('请先创建一个零件实体,再生成工程图', 'err'); return; } + const drawing = createDrawing({ name: doc.name, units: doc.units, format: 'A4', orientation: 'landscape', scale: 1 }); + for (const view of layoutThreeViews(doc.bodyMesh, { scale: 1 })) addView(drawing, view); + doc.drawings.push(drawing); toast('已生成 A4 第一角三视图', 'ok'); + } else if (action === 'dimension') { + const drawing = doc.drawings.at(-1); + if (!drawing) { toast('请先生成工程图', 'err'); return; } + const bb = doc.bbox; const width = bb ? bb.max[0] - bb.min[0] : 0; + addDimension(drawing, { value: width, text: fmt(width) + ' mm', a: [35, 170], b: [95, 170] }); + toast('已添加模型宽度尺寸', 'ok'); + } else if (action === 'section') { + const drawing = doc.drawings.at(-1); + if (!drawing) { toast('请先生成工程图', 'err'); return; } + addSectionView(drawing, { name: 'A-A 剖视图', mesh: doc.bodyMesh }); toast('已添加关联剖视视图', 'ok'); + } else if (action === 'export') { + const drawing = doc.drawings.at(-1); + if (!drawing) { toast('请先生成工程图', 'err'); return; } + download((drawing.name || doc.name || '工程图') + '.svg', renderDrawingSVG(drawing), 'image/svg+xml'); + toast('工程图 SVG 已导出', 'ok'); + } else if (action === 'png') { + const drawing = doc.drawings.at(-1); + if (!drawing) { toast('请先生成工程图', 'err'); return; } + exportDrawingPNG(drawing); + } + renderProps(); updateDrawingPreview(); updateChrome(); +} + +function exportDrawingPNG(drawing) { + const svg = renderDrawingSVG(drawing); + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob), img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); canvas.width = 1600; canvas.height = Math.round(1600 * drawing.sheet.height / drawing.sheet.width); + const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + canvas.toBlob(png => { const purl = URL.createObjectURL(png); const a = document.createElement('a'); a.href = purl; a.download = (drawing.name || '工程图') + '.png'; a.click(); setTimeout(() => URL.revokeObjectURL(purl), 3000); toast('工程图 PNG 已导出', 'ok'); }, 'image/png'); + URL.revokeObjectURL(url); + }; + img.onerror = () => { URL.revokeObjectURL(url); toast('PNG 导出失败,请改用 SVG', 'err'); }; + img.src = url; +} + function setTool(t) { if (mode !== 'sketch') { toast('请先进入草图模式', 'err'); return; } ui.setTool(t); @@ -209,6 +364,9 @@ function setTool(t) { rect: '矩形:单击一角→拖到对角', circle: '圆:单击圆心→拖出半径', arc: '圆弧:依次点三点确定圆弧', + ellipse: '椭圆:单击中心→单击确定长短半轴', + slot: '长圆槽:依次点两端中心→点边界确定宽度', + spline: '样条:连续点击控制点,双击结束', trim: '裁剪:单击要删除的实体', dim: '标注:点实体(线/圆/弧)或两点;双击标注改数值', h: '水平约束:单击直线', @@ -239,8 +397,19 @@ function newSketch(plane) { toast(`已新建${PLANES[plane].name}草图:${sk.name}`, 'ok'); } +function newSketchReference(referenceId) { + const ref = doc.resolveReference(referenceId); + if (!ref || ref.type !== 'plane') { toast('只能在参考基准面上创建草图', 'err'); return; } + pushUndo(); + const sk = doc.addSketch({ plane: 'front', planeRefId: referenceId, name: `${ref.name || '参考面'}草图` }); + enterSketch(sk.id); + switchTab('sketch'); + toast(`已在${ref.name || '参考基准面'}上新建草图`, 'ok'); +} + function enterSketch(id) { - const sk = doc.sketchById(id); + const stored = doc.sketchById(id); + const sk = stored ? doc.resolveSketch(stored) : null; if (!sk) return; mode = 'sketch'; selFeature = null; @@ -251,7 +420,9 @@ function enterSketch(id) { updateToolButtons(); updateHUD(); updateStatus(); - setTip(`编辑草图:${sk.name}(${PLANES[sk.plane].name},偏移 ${fmt(sk.offset)})`); + updateChrome(); + const planeName = sk.planeRefId ? (doc.referenceById(sk.planeRefId)?.name || '参考基准面') : (PLANES[sk.plane]?.name || '自定义基准面'); + setTip(`编辑草图:${sk.name}(${planeName},偏移 ${fmt(sk.offset)})`); } function exitSketch() { @@ -265,6 +436,7 @@ function exitSketch() { updateToolButtons(); updateHUD(); updateStatus(); + updateChrome(); setTip('模型模式:旋转视图,单击实体可选中特征'); } @@ -278,23 +450,73 @@ function setSelFeature(id) { } // ================= 重建 & 刷新 ================= -function rebuildAndRefresh() { +function rebuildAndRefresh(options = {}) { + const hadBody = Boolean(doc.bodyMesh); const mesh = doc.rebuild(); - vp.updateBody(mesh); + if (workspace === 'assembly' && doc.assembly?.components?.length) { + vp.updateComponents(doc.assembly.components.map(c => ({ ...c, renderMesh: transformedMesh(c.mesh, c.transform) }))); + } else { + vp.updateBody(mesh); + } + // 首次生成或打开实体时自动取景;后续编辑保留用户当前缩放和观察角度。 + if ((options.fit || !hadBody) && mesh) vp.fitToModel(); + updateDrawingPreview(); vp.setDeltas(doc.deltaMeshes); vp.buildPickTargets(doc.deltaMeshes); if (ui.active && ui.sk && doc.sketchById(ui.sk.id)) ui.render(); else if (ui.active) ui.setSketch(null); - tree.render({ activeSketchId: mode === 'sketch' && ui.sk ? ui.sk.id : null, selFeatureId: selFeature, onClickSketch: id => enterSketch(id) }); + if (workspace === 'assembly' && doc.assembly) renderAssemblyTree(); + else tree.render({ activeSketchId: mode === 'sketch' && ui.sk ? ui.sk.id : null, selFeatureId: selFeature, onClickSketch: id => enterSketch(id) }); renderProps(); updateHUD(); updateStatus(); + updateChrome(); updateUndoRedo(); const nErr = doc.features.filter(f => f.error).length; if (nErr) { const f = doc.features.find(x => x.error); setTip(`重建完成,${nErr} 个特征失败:${f.error}`); } else if (mode === 'model') setTip('重建完成'); } +function updateDrawingPreview() { + const show = workspace === 'drawing' && doc.drawings.length > 0; + if (drawingPreview) { + drawingPreview.classList.toggle('hidden', !show); + viewportEl.style.visibility = show ? 'hidden' : ''; + if (show) drawingPreview.innerHTML = renderDrawingSVG(doc.drawings.at(-1)); + } +} + +function renderAssemblyTree() { + treeEl.innerHTML = ''; + treeEl.appendChild(el('div', 'tnode sel', '' + esc(doc.assembly.name) + '')); + const child = el('div', 'tchild'); + for (const c of doc.assembly.components) { + const row = el('div', 'tnode' + (c.id === selComponent || (!selComponent && c.fixed) ? ' sel' : ''), '' + esc(c.name) + '' + (c.fixed ? '固定' : '') + ''); + row.title = '单击选择组件,双击打开组件'; + row.addEventListener('click', () => selectAssemblyComponent(c.id)); + row.addEventListener('dblclick', () => openAssemblyComponent(c.id)); + child.appendChild(row); + } + const mates = el('div', 'tnode ghost', '配合(' + doc.assembly.mates.length + ')'); + child.appendChild(mates); treeEl.appendChild(child); +} + +function selectAssemblyComponent(id) { + selComponent = String(id); + renderAssemblyTree(); + renderProps(); +} + +function openAssemblyComponent(id) { + const component = doc.assembly?.components?.find(c => String(c.id) === String(id)); + if (!component) return; + workspace = 'part'; + switchTab('feature'); + selComponent = String(id); + vp.fitToModel(); + setTip(`已打开组件:${component.name}(当前装配组件引用同一零件文档)`); +} + function updateUndoRedo() { const btnU = $('btn-undo'), btnR = $('btn-redo'); if (!btnU) return; @@ -312,6 +534,29 @@ function updateHUD() { } } +function updateChrome() { + const nErr = doc.features.filter(f => f.error).length; + const docName = $('chrome-doc-name'); + const modeEl = $('chrome-mode'); + const stateEl = $('chrome-state'); + const dot = $('chrome-status-dot'); + if (docName) docName.textContent = doc.name || '未命名零件'; + if (modeEl) modeEl.textContent = workspace === 'assembly' ? '装配工作区' : workspace === 'drawing' ? '工程图工作区' : mode === 'sketch' ? '草图编辑' : '3D 模型'; + if (stateEl) { + stateEl.textContent = nErr ? `${nErr} 个错误` : (mode === 'sketch' ? '编辑中' : '就绪'); + stateEl.classList.toggle('err', Boolean(nErr)); + } + if (dot) dot.style.background = nErr ? 'var(--err)' : 'var(--ok)'; + const treeMeta = $('tree-meta'); + if (treeMeta) treeMeta.textContent = `草图 ${doc.sketches.length} · 特征 ${doc.features.length}`; + const propsMeta = $('props-meta'); + if (propsMeta) propsMeta.textContent = workspace === 'assembly' ? '装配' : workspace === 'drawing' ? '工程图' : mode === 'sketch' ? '草图' : (selFeature ? '特征' : '文档'); + if (emptyState) { + const empty = mode === 'model' && doc.features.length === 0 && doc.sketches.length === 0 && aiPanel?.classList.contains('hidden'); + emptyState.classList.toggle('hidden', !empty); + } +} + function updateStatus() { const nErr = doc.features.filter(f => f.error).length; let s = `${doc.sketches.length} 草图 · ${doc.features.length} 特征`; @@ -322,21 +567,56 @@ function updateStatus() { s += ` · 尺寸 ${fmt(d[0])}×${fmt(d[1])}×${fmt(d[2])} mm`; } $('sb-doc').innerHTML = s; + const sbMode = $('sb-mode'); + if (sbMode) sbMode.textContent = workspace === 'assembly' ? '装配' : workspace === 'drawing' ? '工程图' : mode === 'sketch' ? '草图编辑' : '3D 模型'; + const sbEngine = $('sb-engine'); + if (sbEngine) sbEngine.textContent = 'AI:统一 Agent'; } // ================= 属性面板 ================= function renderProps() { propsEl.innerHTML = ''; + if (workspace === 'assembly' && doc.assembly) { renderAssemblyProps(); return; } + if (workspace === 'drawing' && doc.drawings.length) { renderDrawingProps(); return; } if (mode === 'sketch' && ui.sk) { renderSketchProps(ui.sk); return; } if (selFeature) { const f = doc.featureById(selFeature); if (f) { renderFeatureForm(f, false); return; } } renderDefaultProps(); } +function renderAssemblyProps() { + const a = doc.assembly; + propsEl.appendChild(el('div', 'prop-sec', '当前装配体')); + propsEl.appendChild(el('div', 'prop-row', '' + esc(a.name) + '')); + propsEl.appendChild(el('div', 'prop-row', '' + a.components.length + '')); + propsEl.appendChild(el('div', 'prop-row', '' + a.mates.length + '')); + propsEl.appendChild(el('div', 'prop-sec', '装配树')); + for (const c of a.components) { + const row = el('div', 'ent-item' + (c.id === selComponent || (!selComponent && c.fixed) ? ' sel' : ''), '组件' + esc(c.name) + ''); + row.title = '单击选择组件,双击打开组件'; + row.addEventListener('click', () => selectAssemblyComponent(c.id)); + row.addEventListener('dblclick', () => openAssemblyComponent(c.id)); + propsEl.appendChild(row); + } + if (a.lastInterferences) propsEl.appendChild(el('div', 'prop-note ' + (a.lastInterferences.length ? 'prop-error' : 'prop-ok'), a.lastInterferences.length ? ('干涉:' + a.lastInterferences.length + ' 处') : '未检测到干涉')); + propsEl.appendChild(el('div', 'prop-note', '提示:可在上方“装配”选项卡插入零件、添加配合、检查干涉和切换爆炸视图。')); +} + +function renderDrawingProps() { + const d = doc.drawings.at(-1); + propsEl.appendChild(el('div', 'prop-sec', '当前工程图')); + propsEl.appendChild(el('div', 'prop-row', '' + d.format + ' ' + d.orientation + '')); + propsEl.appendChild(el('div', 'prop-row', '' + d.views.length + '')); + propsEl.appendChild(el('div', 'prop-row', '' + d.dimensions.length + '')); + propsEl.appendChild(el('div', 'prop-sec', '标题栏')); + propsEl.appendChild(el('div', 'prop-note', '零件:' + esc(d.title.partName || doc.name) + '\n单位:' + d.units + ' · 比例:' + d.scale)); + propsEl.appendChild(el('div', 'prop-note', '提示:工程图支持第一角三视图、基础尺寸、剖视和 SVG 导出。')); +} + function renderSketchProps(sk) { propsEl.appendChild(el('div', 'prop-sec', '当前草图')); const rows = [ ['名称', sk.name], - ['平面', PLANES[sk.plane].name], + ['平面', sk.planeRefId ? (doc.referenceById(sk.planeRefId)?.name || '参考基准面') : (PLANES[sk.plane]?.name || '自定义基准面')], ['偏移', fmt(sk.offset) + ' mm'], ['实体', sk.entities.length], ['约束', sk.constraints.length], @@ -368,7 +648,7 @@ function renderSketchProps(sk) { const FEAT_LABEL = { extrude: '拉伸凸台', cut: '拉伸切除', revolve: '旋转', - cylinder: '圆柱体', cylcut: '圆柱切除', + cylinder: '圆柱体', cylcut: '圆柱切除', sweep: '扫描', loft: '放样', rib: '筋', chamfer: '实体倒角', fillet: '实体圆角', }; function renderFeatureForm(f, isNew) { @@ -387,6 +667,19 @@ function renderFeatureForm(f, isNew) { propsEl.appendChild(r); fields.push([key, r.querySelector('input'), 'chk']); }; + const addText = (key, label, def = '', hint = '') => { + const r = el('div', 'prop-row', ``); + if (hint) r.title = hint; + propsEl.appendChild(r); + fields.push([key, r.querySelector('input'), 'txt']); + }; + const addSelect = (key, label, options, def) => { + const value = p[key] ?? def; + const html = options.map(([v, text]) => ``).join(''); + const r = el('div', 'prop-row', ``); + propsEl.appendChild(r); + fields.push([key, r.querySelector('select'), 'txt']); + }; const addName = () => { const r = el('div', 'prop-row', ``); propsEl.appendChild(r); @@ -395,14 +688,20 @@ function renderFeatureForm(f, isNew) { switch (f.type) { case 'extrude': + addSelect('mode', '终止条件', [['blind', '固定深度'], ['throughAll', '贯穿全部'], ['symmetric', '对称'], ['toNext', '到下一面'], ['toFace', '到指定面'], ['toBody', '到实体']], p.mode || (p.throughAll ? 'throughAll' : (p.symmetric ? 'symmetric' : 'blind'))); addNum('depth', '深度', 10, '拉伸距离 (mm)'); - addChk('symmetric', '双向拉伸'); + addText('targetFaceRef', '目标面引用', '', '到指定面时填写基准面 ID,例如 front 或参考几何数字 ID'); + addText('targetBodyRef', '目标实体', '', '当前单实体文档填写 current'); + addNum('offset', '终止偏置', 0, '沿拉伸方向的附加距离 (mm)'); addChk('flip', '反向'); addName(); break; case 'cut': + addSelect('mode', '终止条件', [['blind', '固定深度'], ['throughAll', '贯穿全部'], ['symmetric', '对称'], ['toNext', '到下一面'], ['toFace', '到指定面'], ['toBody', '到实体']], p.mode || (p.throughAll ? 'throughAll' : (p.symmetric ? 'symmetric' : 'blind'))); addNum('depth', '深度', 5, '切除深度 (mm)'); - addChk('throughAll', '完全贯穿'); + addText('targetFaceRef', '目标面引用', '', '到指定面时填写基准面 ID'); + addText('targetBodyRef', '目标实体', '', '当前单实体文档填写 current'); + addNum('offset', '终止偏置', 0, '沿拉伸方向的附加距离 (mm)'); addChk('flip', '反向'); addName(); break; @@ -424,6 +723,35 @@ function renderFeatureForm(f, isNew) { addChk('through', '完全贯穿'); addName(); break; + case 'sweep': + addNum('twistDegrees', '扭转角度', 0, '沿路径累计扭转(度)'); + addName(); + propsEl.appendChild(el('div', 'prop-note', `路径草图 #${p.pathSketchId ?? '直接路径'} · 截面草图 #${p.profileSketchId ?? '直接截面'} · 标架 ${p.frameMode || 'parallelTransport'}`)); + break; + case 'loft': + addNum('resampleCount', '重采样点数', 32, '每个截面统一后的顶点数'); + addName(); + propsEl.appendChild(el('div', 'prop-note', `截面草图:${(p.profileSketchIds || []).join('、') || '直接截面数据'}`)); + break; + case 'rib': + addNum('thickness', '筋宽', 3, '开放路径在草图平面内扩展的宽度 (mm)'); + addNum('extent', '延伸范围', 30, '沿草图法向的延伸距离 (mm)'); + addSelect('side', '路径侧向', [['both', '双侧'], ['left', '左侧'], ['right', '右侧']], 'both'); + addNum('draftDegrees', '拔模角', 0, '范围 -45° 到 45°'); + addChk('flip', '反向'); + addName(); + break; + case 'chamfer': + addNum('distance', '倒角距离', 5, '当前受限实现支持凸多面体的一条直棱'); + addName(); + propsEl.appendChild(el('div', 'prop-note', `稳定棱边引用:${p.edgeRefs?.length || 0} 条`)); + break; + case 'fillet': + addNum('radius', '圆角半径', 5, '当前受限实现支持凸多面体的一条直棱'); + addNum('segments', '圆弧分段', 8, '分段越多,圆弧过渡越平滑'); + addName(); + propsEl.appendChild(el('div', 'prop-note', `稳定棱边引用:${p.edgeRefs?.length || 0} 条`)); + break; default: addName(); } @@ -434,7 +762,7 @@ function renderFeatureForm(f, isNew) { const params = {}; for (const [k, inp, t] of fields) { if (k === '__name') continue; - params[k] = t === 'chk' ? !!inp.checked : (parseFloat(inp.value) || 0); + params[k] = t === 'chk' ? !!inp.checked : (t === 'num' ? (parseFloat(inp.value) || 0) : inp.value.trim()); } const nameV = (fields.find(x => x[0] === '__name') || [])[1]; pushUndo(); @@ -496,8 +824,8 @@ function featureFlow(type) { return; } const presets = { - extrude: { depth: 10, symmetric: false, flip: false }, - cut: { depth: 5, throughAll: false, flip: false }, + extrude: { mode: 'blind', depth: 10, offset: 0, flip: false }, + cut: { mode: 'blind', depth: 5, offset: 0, flip: false }, revolve: { revolveDegrees: 360, flip: false }, }; const names = { extrude: '拉伸', cut: '切除', revolve: '旋转' }; @@ -505,6 +833,72 @@ function featureFlow(type) { renderFeatureForm(dummy, true); } +function sketchChoices() { + return doc.sketches.map(s => `#${s.id} ${s.name}(${s.entities.length}实体)`).join('\n'); +} + +function advancedFeatureFlow(type) { + if (mode === 'sketch') exitSketch(); + if ((type === 'sweep' || type === 'loft') && doc.sketches.length < 2) { toast('扫描/放样至少需要两张草图', 'err'); return; } + if (type === 'rib' && (!doc.bodyManifold || doc.sketches.length < 1)) { toast('筋需要已有主体和一张开放路径草图', 'err'); return; } + const choices = sketchChoices(); + if (type === 'sweep') { + const pathRaw = window.prompt(`输入路径草图 ID:\n${choices}`, String(doc.sketches[0].id)); + if (pathRaw === null) return; + const profileRaw = window.prompt(`输入截面草图 ID:\n${choices}`, String(doc.sketches[1].id)); + if (profileRaw === null) return; + const pathSketchId = Number(pathRaw), profileSketchId = Number(profileRaw); + if (!doc.sketchById(pathSketchId) || !doc.sketchById(profileSketchId) || pathSketchId === profileSketchId) { toast('路径/截面草图 ID 无效', 'err'); return; } + pushUndo(); + const f = doc.addFeature({ type: 'sweep', name: '扫描', params: { pathSketchId, profileSketchId, frameMode: 'parallelTransport', twistDegrees: 0, capStart: true, capEnd: true } }); + selFeature = f.id; + } else if (type === 'loft') { + const raw = window.prompt(`按顺序输入至少两个截面草图 ID,用逗号分隔:\n${choices}`, doc.sketches.slice(0, 2).map(s => s.id).join(',')); + if (raw === null) return; + const profileSketchIds = raw.split(/[,,\s]+/).filter(Boolean).map(Number); + if (profileSketchIds.length < 2 || profileSketchIds.some(id => !doc.sketchById(id))) { toast('放样截面草图 ID 无效', 'err'); return; } + pushUndo(); + const f = doc.addFeature({ type: 'loft', name: '放样', params: { profileSketchIds, resampleCount: 32, capStart: true, capEnd: true, alignment: 'auto' } }); + selFeature = f.id; + } else if (type === 'rib') { + const raw = window.prompt(`输入开放路径草图 ID:\n${choices}`, String(doc.sketches.at(-1).id)); + if (raw === null) return; + const sketchId = Number(raw); + if (!doc.sketchById(sketchId)) { toast('筋路径草图 ID 无效', 'err'); return; } + const thickness = Number(window.prompt('输入筋宽(mm)', '3')); + const extent = Number(window.prompt('输入沿草图法向的延伸范围(mm)', '30')); + if (!(thickness > 0) || !(extent > 0)) { toast('筋宽和延伸范围必须大于 0', 'err'); return; } + pushUndo(); + const f = doc.addFeature({ type: 'rib', sketchId, name: '筋', params: { thickness, extent, side: 'both', draftDegrees: 0, flip: false } }); + selFeature = f.id; + } + rebuildAndRefresh(); + toast(`已创建${FEAT_LABEL[type]}`, 'ok'); +} + +function referenceFlow(kind) { + if (kind !== 'offset') return; + const source = window.prompt('输入基准面:front(前视)/ top(上视)/ right(右视)', 'front'); + if (source === null) return; + if (!PLANES[source]) { toast('基准面必须是 front、top 或 right', 'err'); return; } + const raw = window.prompt('输入偏移距离(mm)', '10'); + if (raw === null) return; + const distance = Number(raw); + if (!Number.isFinite(distance)) { toast('偏移距离无效', 'err'); return; } + pushUndo(); + const ref = doc.addReference({ type: 'plane', name: `偏移基准面${doc.references.length + 1}`, sourceRefs: [source], params: { kind: 'offset', distance } }); + tree.render({ activeSketchId: null, selFeatureId: selFeature, onClickSketch: id => enterSketch(id) }); + renderProps(); updateStatus(); + toast(`已创建 ${ref.name}`, 'ok'); +} + +function beginEdgeFeature(type) { + if (mode !== 'model' || !doc.bodyMesh) { toast('请先创建一个实体主体', 'err'); return; } + pendingEdgeFeature = type; + setTip(`请在模型的一条凸直棱附近单击,创建${FEAT_LABEL[type]}`); + toast(`已进入${FEAT_LABEL[type]}选边模式`); +} + // ================= 撤销 / 重做 ================= function pushUndo() { undoStack.push(doc.toJSON()); @@ -515,12 +909,19 @@ function pushUndo() { function restore(snap) { const d = Doc.fromJSON(snap); + try { doc.bodyManifold?.delete?.(); } catch { /* 已释放对象直接丢弃 */ } + doc.bodyManifold = null; + doc.bodyMesh = null; + doc.deltaMeshes = {}; + doc.bbox = null; doc.name = d.name; doc.units = d.units; - doc.sketches = d.sketches; doc.features = d.features; - doc.sketchSeq = d.sketchSeq; doc.featureSeq = d.featureSeq; + doc.sketches = d.sketches; doc.references = d.references; doc.features = d.features; + doc.dimensions = d.dimensions; doc.parameters = d.parameters; + doc.assembly = d.assembly; doc.drawings = d.drawings; + doc.sketchSeq = d.sketchSeq; doc.referenceSeq = d.referenceSeq; doc.featureSeq = d.featureSeq; // 若正在编辑的草图被删除 → 退出草图模式 if (mode === 'sketch' && !doc.sketchById(ui.sk.id)) exitSketch(); - else if (mode === 'sketch') { ui.setSketch(doc.sketchById(ui.sk.id)); vp.enterSketchMode(ui.sk); } + else if (mode === 'sketch') { const sk = doc.resolveSketch(doc.sketchById(ui.sk.id)); ui.setSketch(sk); vp.enterSketchMode(sk); } if (selFeature && !doc.featureById(selFeature)) selFeature = null; rebuildAndRefresh(); } @@ -570,11 +971,12 @@ const openInput = (() => { pushUndo(); const d = Doc.fromJSON(data); doc.name = d.name; doc.units = d.units; - doc.sketches = d.sketches; doc.features = d.features; - doc.sketchSeq = d.sketchSeq; doc.featureSeq = d.featureSeq; + doc.sketches = d.sketches; doc.references = d.references; doc.features = d.features; + doc.dimensions = d.dimensions; doc.parameters = d.parameters; doc.assembly = d.assembly; doc.drawings = d.drawings; + doc.sketchSeq = d.sketchSeq; doc.referenceSeq = d.referenceSeq; doc.featureSeq = d.featureSeq; if (mode === 'sketch') exitSketch(); selFeature = null; - rebuildAndRefresh(); + rebuildAndRefresh({ fit: true }); toast(`已打开工程:${doc.name}`, 'ok'); } catch (e) { toast('打开失败:' + (e.message || e), 'err'); @@ -609,16 +1011,23 @@ function editDim(dim) { } // ================= AI 智能体 ================= -function setAiMode(label, isLLM) { - aiModeBadge.textContent = label; - aiModeBadge.classList.toggle('llm', !!isLLM); +function setAiMode() { + aiModeBadge.textContent = 'Agent 聊天'; + aiModeBadge.classList.add('llm'); + aiModeBadge.title = 'Agent 聊天:LLM 自主读取项目、观察模型并调用建模工具'; + if (aiGuide) aiGuide.textContent = '聊天 Agent:直接描述目标。LLM 会自主读取项目文件、查看当前模型并调用建模工具。'; + if ($('sb-engine')) $('sb-engine').textContent = 'AI:聊天 Agent'; } function toggleAI() { aiPanel.classList.toggle('hidden'); - if (!aiPanel.classList.contains('hidden') && !aiChat.children.length) { - appendMsg('bot', '我是你的建模智能体。用一句话描述零件,例如:\n"做一个 120×80×15 的底板,四角各一个直径8的圆孔"\n支持指令:倒角、圆角、抽壳、线性/圆周阵列、镜像、凸台、多草图组合。\n内置解析器离线可用;点击 ⚙ 可接入任意 OpenAI 兼容大模型,支持更复杂的描述和多轮对话。'); - } + if (!aiPanel.classList.contains('hidden')) ensureAIWelcome(); + updateChrome(); +} + +function ensureAIWelcome() { + if (aiChat.children.length) return; + appendMsg('bot', '你好,直接告诉我你想做什么即可。我会自主读取 MiniSW 项目源码、查看当前模型,并调用建模工具完成任务。', 'ok'); } function appendMsg(role, text, cls = '') { @@ -634,61 +1043,185 @@ function appendMsg(role, text, cls = '') { } function modelStateSummary() { - const sks = doc.sketches.map(s => `${s.name}(${s.plane},${s.entities.length}实体)`); - const fes = doc.features.map(f => `${f.name}(${f.type}${f.error ? ',错误' : ''})`); - return `草图: ${sks.join(', ') || '无'}; 特征: ${fes.join(', ') || '无'}`; + const observed = inspectDocument(doc); + return JSON.stringify({ + name: doc.name, + hasBody: observed.hasBody, + status: observed.status, + dimensions: observed.dimensions, + selectedFeatureId: selFeature, + lastFeatureId: doc.features.at(-1)?.id ?? null, + references: doc.references.map(r => ({ id: r.id, name: r.name, type: r.type })), + sketches: doc.sketches.map(s => ({ id: s.id, name: s.name, plane: s.plane, entities: s.entities.length })), + features: doc.features.map(f => ({ id: f.id, name: f.name, type: f.type, error: f.error || null })), + assembly: doc.assembly ? { components: doc.assembly.components.map(c => ({ id: c.id, name: c.name })), mates: doc.assembly.mates.length } : null, + drawings: doc.drawings.map(d => ({ name: d.name, views: d.views.length })), + }); } function clearAI() { aiChat.innerHTML = ''; aiHistory.length = 0; - appendMsg('bot', '对话已清空。用一句话描述零件,例如:\n"做一个 120×80×15 的底板,四角各一个直径8的圆孔"\n新指令支持:倒角、圆角、抽壳、线性/圆周阵列、镜像、凸台。'); + appendMsg('bot', '对话已清空。'); + ensureAIWelcome(); } -async function sendAI() { +function promptStartsNewPart(text) { + return /^(?:做|生成|创建|新建)\s*(?:一个|一件|一只|个)?/.test(text.trim()); +} + +async function sendAILegacyPlan() { const text = aiInput.value.trim(); if (!text) { aiInput.focus(); return; } appendMsg('user', text); + debugRecord('请求开始', { text, mode: 'unified', document: modelStateSummary() }); aiInput.value = ''; aiSend.disabled = true; aiSend.innerHTML = ''; try { - let ops = null, usedLLM = false; - ops = parseNaturalLanguage(text); + let ops = null, usedLLM = false, llmElapsedMs = 0, llmCallCount = 0, llmCfg = null, llmCtx = {}; + const request = interpretAgentRequest(text, doc); + const localRequest = decideLocalRequest(text, doc); + debugRecord('统一 Agent 本地闸门', { parsed: request, accepted: localRequest }); + if (localRequest?.kind === 'query') { + appendMsg('bot', localRequest.reply, 'ok'); + aiHistory.push({ role: 'user', content: text }, { role: 'assistant', content: localRequest.reply }); + return; + } + if (localRequest?.kind === 'delete' || localRequest?.kind === 'update') { + const before = doc.toJSON(); + pushUndo(); + try { + const reply = applyAgentMutation(doc, localRequest); + rebuildAndRefresh(); + const errors = doc.features.filter(f => f.error); + if (errors.length) { + restore(before); + throw new Error(`修改后重建失败:${errors[0].error}`); + } + appendMsg('bot', `${reply}\n${describeInspection(inspectDocument(doc))}`, 'ok'); + aiHistory.push({ role: 'user', content: text }, { role: 'assistant', content: reply }); + return; + } catch (e) { + undoStack.pop(); + updateUndoRedo(); + throw e; + } + } + ops = localRequest?.kind === 'ops' ? localRequest.ops : null; if (!ops) { const cfg = getLLMSettings(); if (cfg.baseURL && cfg.apiKey && cfg.model) { usedLLM = true; const ctx = { modelState: modelStateSummary(), history: aiHistory.slice() }; - ops = await runLLM(text, cfg, ctx); - } - else throw new Error('内置解析器无法识别该描述。可尝试更标准的说法(如"板、孔、圆柱、倒角、圆角、抽壳、阵列、镜像、凸台"等关键词),或点击 ⚙ 配置 LLM 智能体。'); + ctx.onStage = label => { aiSend.textContent = label; debugRecord('Agent 阶段', label); }; + ctx.onTrace = event => debugRecord(`LLM 调用:${event.stage || 'unknown'}`, event); + llmCfg = cfg; + llmCtx = ctx; + const started = performance.now(); + aiSend.textContent = '统一 Agent 快速理解中…'; + try { ops = await runLLM(text, cfg, ctx); } + catch (llmError) { throw llmError; } + finally { llmElapsedMs = performance.now() - started; } + llmCallCount += Number(ops?.agentMeta?.calls || 0); + debugRecord('规划结果', { calls: ops?.agentMeta?.calls || 0, ops }); + } else throw new Error('当前请求需要统一 Agent 的 LLM 补全,但尚未完成配置。请点击 ⚙ 填写 Base URL、API Key 和模型名。'); } const verr = validateOps(ops); + debugRecord('DSL 结构校验', verr || '通过'); if (verr) throw new Error('操作序列校验失败:' + verr); + let preflightError = validateOpsAgainstDocument(doc, ops); + debugRecord('文档语义预检', preflightError || '通过'); + if (preflightError && usedLLM) { + const repairStarted = performance.now(); + aiSend.textContent = '执行前检查失败,正在让 LLM 修正…'; + ops = await repairLLMPlan(text, llmCfg, { ...llmCtx, modelState: modelStateSummary() }, ops, `执行前检查:${preflightError}`); + llmElapsedMs += performance.now() - repairStarted; + llmCallCount += Number(ops?.agentMeta?.calls || 1); + const repairedSchemaError = validateOps(ops); + preflightError = validateOpsAgainstDocument(doc, ops); + if (repairedSchemaError || preflightError) throw new Error('LLM 修正后的计划仍不可执行:' + (repairedSchemaError || preflightError)); + } else if (preflightError) throw new Error('操作计划不可执行:' + preflightError); + const queryOp = ops.find(op => op.op === 'query_document'); + if (queryOp) { + const answer = String(queryOp.answer || '当前模型没有可回答的信息。'); + appendMsg('bot', `${answer}\nLLM 共调用 ${llmCallCount} 次,处理耗时 ${(llmElapsedMs / 1000).toFixed(1)} 秒。`, 'ok'); + aiHistory.push({ role: 'user', content: text }, { role: 'assistant', content: answer }); + if (aiHistory.length > 12) aiHistory.splice(0, aiHistory.length - 12); + return; + } const expanded = expandOps(ops); pushUndo(); - try { executeOps(doc, ops); } - catch (e) { - const snap = undoStack.pop(); - updateUndoRedo(); - if (snap) restore(snap); - else rebuildAndRefresh(); - throw e; + // “做一个/生成一个”表示新零件。若当前已有模型,先清空旧特征, + // 避免新零件的筋、倒角等特征继承到旧主体而出现无主体错误。 + if (doc.features.length && promptStartsNewPart(text)) { + doc.reset(); + selFeature = null; } - rebuildAndRefresh(); - setAiMode(usedLLM ? 'LLM 智能体' : '内置解析器', usedLLM); - // 记录对话历史(用于 LLM 多轮) + const beforeExec = doc.toJSON(); + if (ops.some(o => String(o.op).startsWith('assembly'))) workspace = 'assembly'; + if (ops.some(o => String(o.op).startsWith('drawing'))) workspace = 'drawing'; + let runOps = ops; + let created = null; + const corrections = []; + for (let attempt = 0; attempt < 2; attempt++) { + try { created = executeOps(doc, runOps); } + catch (e) { + debugRecord('执行错误', { attempt: attempt + 1, message: e.message || String(e), ops: runOps }); + restore(beforeExec); + if (usedLLM && attempt === 0) { + const repairStarted = performance.now(); + aiSend.textContent = '根据错误纠正中…'; + runOps = await repairLLMPlan(text, llmCfg, { ...llmCtx, modelState: modelStateSummary() }, runOps, e.message || String(e)); + llmElapsedMs += performance.now() - repairStarted; + llmCallCount += Number(runOps?.agentMeta?.calls || 1); + corrections.push('LLM 根据执行错误重新规划'); + continue; + } + throw e; + } + rebuildAndRefresh(); + const createdIds = new Set(created.features || []); + const errors = doc.features.filter(f => createdIds.has(f.id) && f.error); + if (!errors.length || attempt === 1) break; + debugRecord('几何重建错误', { attempt: attempt + 1, errors: errors.map(f => ({ name: f.name, type: f.type, error: f.error })) }); + if (usedLLM) { + const errorText = errors.map(f => `${f.name}(${f.type}):${f.error}`).join(';'); + restore(beforeExec); + const repairStarted = performance.now(); + aiSend.textContent = '根据建模错误纠正中…'; + runOps = await repairLLMPlan(text, llmCfg, { ...llmCtx, modelState: modelStateSummary() }, runOps, errorText); + llmElapsedMs += performance.now() - repairStarted; + llmCallCount += Number(runOps?.agentMeta?.calls || 1); + corrections.push('LLM 根据几何内核错误重新规划'); + continue; + } + const repaired = repairOps(runOps, errors); + if (!repaired.removed.length || !repaired.ops.length) break; + corrections.push(...repaired.removed); + restore(beforeExec); + runOps = repaired.ops; + } + // 统一 Agent 默认不再追加生成后视觉复核;结构校验、文档预检和几何重建 + // 已经覆盖执行安全边界,避免每个普通请求再额外增加一次远程调用。 + if (created?.deleted?.some(item => String(item.id) === String(selFeature))) setSelFeature(null); + setAiMode(); + // 记录最近对话,用于统一 Agent 判断当前上下文。 aiHistory.push({ role: 'user', content: text }); const nSk = doc.sketches.length, nFe = doc.features.length; - const featList = expanded.filter(o => ['extrude', 'cut', 'revolve', 'cylinder', 'cylcut'].includes(o.op)) - .map(o => o.name || o.op).join('、'); + const featList = runOps.flatMap(o => expandOps([o])).filter(o => ['delete_feature', 'update_feature', 'extrude', 'cut', 'revolve', 'cylinder', 'cylcut', 'sweep', 'loft', 'rib', 'chamfer_edge', 'fillet_edge', 'assembly_create', 'drawing_generate'].includes(o.op)) + .map(o => o.op === 'delete_feature' ? `删除特征#${o.featureId}` : o.op === 'update_feature' ? `修改特征#${o.featureId}` : (o.name || o.op)).join('、'); aiHistory.push({ role: 'assistant', content: `已生成 ${expanded.length} 个操作,创建 ${nFe} 个特征:${featList}` }); if (aiHistory.length > 12) aiHistory.splice(0, aiHistory.length - 12); const errs = doc.features.filter(f => f.error); if (errs.length) appendMsg('bot', `模型已生成(${nFe} 个特征),但 ${errs.length} 个特征失败:${errs[0].error}`, 'err'); - else appendMsg('bot', `完成!共 ${expanded.length} 个操作(原始 ${ops.length} 条指令,含高级展开):草图 ${nSk} 张,特征 ${nFe} 个(${featList})。\n特征已加入左侧特征树,可继续手动编辑。`, 'ok'); + else { + const repairNote = corrections.length ? `\n我检测到 ${corrections.join('、')} 不可用,已自动移除后重建。` : ''; + const timing = usedLLM ? `\nLLM 共调用 ${llmCallCount} 次,总耗时 ${(llmElapsedMs / 1000).toFixed(1)} 秒。` : ''; + appendMsg('bot', `完成!共 ${runOps.length} 个操作(原始 ${ops.length} 条指令,含高级展开):草图 ${nSk} 张,特征 ${nFe} 个(${featList})。${repairNote}${timing}\n${describeInspection(inspectDocument(doc))}\n特征已加入左侧特征树,可继续手动编辑。`, 'ok'); + } } catch (e) { + debugRecord('Agent 失败', { message: e.message || String(e), stack: e.stack || null }); appendMsg('bot', '生成失败:' + (e.message || e), 'err'); } finally { aiSend.disabled = false; @@ -696,15 +1229,78 @@ async function sendAI() { } } +async function sendAI() { + const text = aiInput.value.trim(); + if (!text) { aiInput.focus(); return; } + appendMsg('user', text); + aiInput.value = ''; + aiSend.disabled = true; + aiSend.textContent = '处理中…'; + try { + const cfg = getLLMSettings(); + if (!cfg.baseURL || !cfg.apiKey || !cfg.model) throw new Error('请先在设置中填写 Base URL、API Key 和模型名。'); + const toolHandlers = { + inspect_model: async () => ({ inspection: inspectDocument(doc), state: JSON.parse(modelStateSummary()) }), + reset_model: async () => { + pushUndo(); + doc.reset(); + selFeature = null; + rebuildAndRefresh({ fit: true }); + return { ok: true, inspection: inspectDocument(doc) }; + }, + execute_model_ops: async ({ ops } = {}) => { + if (!Array.isArray(ops) || !ops.length) throw new Error('execute_model_ops 需要非空 ops 数组'); + const schemaError = validateOps(ops); + if (schemaError) throw new Error('操作校验失败:' + schemaError); + const preflightError = validateOpsAgainstDocument(doc, ops); + if (preflightError) throw new Error('当前文档无法执行:' + preflightError); + const before = doc.toJSON(); + const mutates = ops.some(op => op.op !== 'query_document'); + if (mutates) pushUndo(); + try { + const created = executeOps(doc, ops); + rebuildAndRefresh({ fit: true }); + const createdIds = new Set(created?.features || []); + const errors = doc.features.filter(feature => createdIds.has(feature.id) && feature.error); + if (errors.length) throw new Error(errors.map(feature => `${feature.name || feature.type}:${feature.error}`).join(';')); + if (ops.some(op => String(op.op).startsWith('assembly'))) workspace = 'assembly'; + if (ops.some(op => String(op.op).startsWith('drawing'))) workspace = 'drawing'; + return { ok: true, created, inspection: inspectDocument(doc) }; + } catch (error) { + restore(before); + if (mutates) { undoStack.pop(); updateUndoRedo(); } + throw error; + } + }, + }; + const ctx = { + history: aiHistory.slice(), + toolHandlers, + onStage: label => { aiSend.textContent = label; }, + }; + const result = await runAgentChat(text, cfg, ctx); + const reply = result.reply || '已完成工具操作。'; + appendMsg('bot', reply, 'ok'); + aiHistory.push({ role: 'user', content: text }, { role: 'assistant', content: reply }); + setAiMode(); + } catch (error) { + appendMsg('bot', '处理失败:' + (error.message || error), 'err'); + } finally { + aiSend.disabled = false; + aiSend.textContent = '发送'; + } +} + function openLLMSettings() { const cfg = getLLMSettings(); aiChat.innerHTML = ''; const box = el('div', 'ai-msg bot'); box.style.width = '100%'; - box.innerHTML = `
LLM 智能体配置(OpenAI 兼容接口)
+ box.innerHTML = `
Agent 的 LLM 配置(OpenAI 兼容接口)
+
聊天 Agent 会自主读取项目文件、查看模型并调用建模工具。
@@ -718,9 +1314,8 @@ function openLLMSettings() { }); const c = getLLMSettings(); const ok = !!(c.baseURL && c.apiKey && c.model); - setAiMode(ok ? 'LLM 智能体' : '内置解析器', ok); - toast(ok ? 'LLM 配置已保存' : '配置不完整,继续使用内置解析器'); - appendMsg('bot', ok ? 'LLM 智能体已就绪,可直接输入复杂描述。' : 'LLM 配置未完成,仍使用内置解析器(标准句式可用)。'); + toast(ok ? 'Agent 配置已保存' : '配置不完整,暂不能调用 LLM', ok ? 'ok' : 'err'); + appendMsg('bot', ok ? 'Agent 配置已保存。' : 'LLM 配置未完成。'); }); $('llm-cancel').addEventListener('click', () => { aiChat.innerHTML = ''; toggleAI(); }); } @@ -733,6 +1328,33 @@ viewportEl.addEventListener('pointerup', e => { const dx = e.clientX - downPos[0], dy = e.clientY - downPos[1]; downPos = null; if (Math.hypot(dx, dy) > 6 || mode !== 'model' || e.button !== 0) return; + if (pendingEdgeFeature) { + try { + const topology = extractTopology(doc.bodyMesh); + const edge = vp.pickBodyEdge(e.clientX, e.clientY, topology.edges); + if (!edge) { toast('没有拾取到棱边,请更靠近可见边线单击', 'err'); return; } + const type = pendingEdgeFeature; + const label = type === 'chamfer' ? '倒角距离' : '圆角半径'; + const rawValue = window.prompt(`输入${label}(mm)`, '5'); + if (rawValue === null) { pendingEdgeFeature = null; setTip(''); return; } + const value = Number(rawValue); + if (!(value > 0)) { toast(`${label}必须大于 0`, 'err'); return; } + pushUndo(); + const params = { edgeRefs: [makeStableEdgeRef(edge, topology)] }; + if (type === 'chamfer') params.distance = value; + else { params.radius = value; params.segments = 8; } + const feature = doc.addFeature({ type, params, name: FEAT_LABEL[type] }); + pendingEdgeFeature = null; + selFeature = feature.id; + rebuildAndRefresh(); + toast(`已创建${FEAT_LABEL[type]}`, feature.error ? 'err' : 'ok'); + setTip(''); + return; + } catch (error) { + toast(error.message || String(error), 'err'); + return; + } + } const fid = vp.pickFeature(e.clientX, e.clientY); setSelFeature(fid); }); @@ -743,6 +1365,8 @@ function buildStatusbar() { statusEl.appendChild(el('span', 'sb-item', 'MiniSW')); statusEl.appendChild(el('span', 'sb-item', '')); statusEl.appendChild(el('span', 'sb-item sb-tip', '')); + statusEl.appendChild(el('span', 'sb-item', '3D 模型')); + statusEl.appendChild(el('span', 'sb-item', 'AI:统一 Agent')); } async function boot() { @@ -779,22 +1403,40 @@ async function boot() { }, onEditFeature: id => { setSelFeature(id); switchTab('feature'); }, onSelectPlane: () => {}, + onNewSketchReference: id => newSketchReference(id), + onSelectReference: id => { + const ref = doc.resolveReference(id); + propsEl.innerHTML = ''; + propsEl.appendChild(el('div', 'prop-sec', ref.name || '参考几何')); + propsEl.appendChild(el('div', 'prop-note', `${ref.type} · #${ref.id}\n${JSON.stringify(ref.params || {})}`)); + }, + onDeleteReference: id => { + pushUndo(); + if (!doc.deleteReference(id)) { undoStack.pop(); updateUndoRedo(); toast('参考几何正在被草图或其他参考引用', 'err'); return; } + rebuildAndRefresh(); + toast('已删除参考几何'); + }, }); // AI 面板事件 aiSend.addEventListener('click', sendAI); aiInput.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendAI(); } }); - aiClose.addEventListener('click', () => aiPanel.classList.add('hidden')); + aiClose.addEventListener('click', () => { aiPanel.classList.add('hidden'); updateChrome(); }); aiSettings.addEventListener('click', openLLMSettings); aiClear.addEventListener('click', clearAI); + $('empty-sketch')?.addEventListener('click', () => newSketch('front')); + $('empty-ai')?.addEventListener('click', () => { + aiPanel.classList.remove('hidden'); + ensureAIWelcome(); + aiInput.focus(); + }); for (const chip of document.querySelectorAll('.chip')) { chip.addEventListener('click', () => { aiInput.value = chip.dataset.ex; sendAI(); }); } - const c0 = getLLMSettings(); - setAiMode((c0.baseURL && c0.apiKey && c0.model) ? 'LLM 智能体' : '内置解析器', !!(c0.baseURL && c0.apiKey && c0.model)); + setAiMode(); // 面板拖拽调整宽度 initPanelResizers(); @@ -815,6 +1457,7 @@ async function boot() { setTimeout(() => { splash.style.opacity = '0'; splash.style.transition = 'opacity .4s'; setTimeout(() => splash.remove(), 450); }, 200); rebuildAndRefresh(); + updateChrome(); updateToolButtons(); setTip('欢迎使用 MiniSW!在"特征"选项卡点击基准面新建草图,或用"AI 智能体"一句话建模。'); setAiModeBtnReady(); @@ -823,6 +1466,8 @@ async function boot() { function setAiModeBtnReady() { // AI 面板默认打开引导 aiPanel.classList.remove('hidden'); + ensureAIWelcome(); + updateChrome(); } boot(); diff --git a/js/math.js b/js/math.js index 1fd780c..2f0f201 100644 --- a/js/math.js +++ b/js/math.js @@ -90,6 +90,14 @@ export const PLANES = { /** 局部 (u,v,w) → 世界坐标;s 为 w 方向符号(±1) */ export function toWorld(u, v, w, planeKey, offset, s) { + if (planeKey && typeof planeKey === 'object') { + const o = planeKey.origin, x = planeKey.xAxis; + const n = planeKey.normal; + const y = planeKey.yAxis || [n[1] * x[2] - n[2] * x[1], n[2] * x[0] - n[0] * x[2], n[0] * x[1] - n[1] * x[0]]; + if (![o, x, y, n].every(a => Array.isArray(a) && a.length >= 3)) return [u, v, w]; + const ww = (offset || 0) + (s || 1) * w; + return [o[0] + x[0] * u + y[0] * v + n[0] * ww, o[1] + x[1] * u + y[1] * v + n[1] * ww, o[2] + x[2] * u + y[2] * v + n[2] * ww]; + } const ww = offset + (s || 1) * w; switch (planeKey) { case 'front': return [u, v, ww]; @@ -101,6 +109,14 @@ export function toWorld(u, v, w, planeKey, offset, s) { /** 世界坐标 → 局部 (u,v,w);s 与 toWorld 的 s 一致 */ export function fromWorld(x, y, z, planeKey, offset, s) { + if (planeKey && typeof planeKey === 'object') { + const o = planeKey.origin, ux = planeKey.xAxis, n = planeKey.normal; + const vy = planeKey.yAxis || [n[1] * ux[2] - n[2] * ux[1], n[2] * ux[0] - n[0] * ux[2], n[0] * ux[1] - n[1] * ux[0]]; + if (![o, ux, vy, n].every(a => Array.isArray(a) && a.length >= 3)) return [x, y, z]; + const q = [x - o[0], y - o[1], z - o[2]]; + const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + return [dot3(q, ux), dot3(q, vy), (dot3(q, n) - (offset || 0)) / (s || 1)]; + } const inv = 1 / (s || 1); switch (planeKey) { case 'front': return [x, y, (z - offset) * inv]; diff --git a/js/sketch.js b/js/sketch.js index d56ed99..787e7e0 100644 --- a/js/sketch.js +++ b/js/sketch.js @@ -6,7 +6,7 @@ export const nextId = () => uid++; export const resetIds = () => { uid = 1; }; // ---------------- 实体 ---------------- -// type: line | rect | circle | arc | centerline +// type: line | rect | circle | arc | ellipse | slot | spline | centerline // 常用方法:tessellate(ent) → 折线点列;segmentsOf(ent) → 线段对列;boundsOf(ent) export function tessellate(ent) { @@ -26,6 +26,34 @@ export function tessellate(ent) { } return pts; } + case 'ellipse': { + const n = 96, pts = [], rot = ent.rotation || 0; + for (let i = 0; i < n; i++) { + const a = i / n * Math.PI * 2, x = ent.rx * Math.cos(a), y = ent.ry * Math.sin(a); + pts.push([ent.c[0] + x * Math.cos(rot) - y * Math.sin(rot), ent.c[1] + x * Math.sin(rot) + y * Math.cos(rot)]); + } + return pts; + } + case 'slot': { + const p1 = ent.p1, p2 = ent.p2, r = Math.max(1e-6, (ent.width || ent.w || 6) / 2); + const dx = p2[0] - p1[0], dy = p2[1] - p1[1], l = Math.hypot(dx, dy) || 1; + const nx = -dy / l, ny = dx / l, pts = [[p1[0] + nx * r, p1[1] + ny * r], [p2[0] + nx * r, p2[1] + ny * r]]; + for (let i = 1; i <= 16; i++) { const a = Math.atan2(ny, nx) - Math.PI * i / 16; pts.push([p2[0] + r * Math.cos(a), p2[1] + r * Math.sin(a)]); } + pts.push([p1[0] - nx * r, p1[1] - ny * r]); + for (let i = 1; i <= 16; i++) { const a = Math.atan2(-ny, -nx) - Math.PI * i / 16; pts.push([p1[0] + r * Math.cos(a), p1[1] + r * Math.sin(a)]); } + return pts; + } + case 'spline': { + const pts = ent.points || []; + if (pts.length < 2) return pts.map(p => p.slice()); + const out = []; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[Math.max(0, i - 1)], b = pts[i], c = pts[i + 1], d = pts[Math.min(pts.length - 1, i + 2)]; + for (let j = 0; j < 8; j++) { const t = j / 8, t2 = t * t, t3 = t2 * t; out.push([0.5 * ((2 * b[0]) + (-a[0] + c[0]) * t + (2 * a[0] - 5 * b[0] + 4 * c[0] - d[0]) * t2 + (-a[0] + 3 * b[0] - 3 * c[0] + d[0]) * t3), 0.5 * ((2 * b[1]) + (-a[1] + c[1]) * t + (2 * a[1] - 5 * b[1] + 4 * c[1] - d[1]) * t2 + (-a[1] + 3 * b[1] - 3 * c[1] + d[1]) * t3)]); } + } + out.push(pts[pts.length - 1].slice()); + return out; + } case 'arc': { const sweep = ent.a1 - ent.a0; const n = Math.max(4, Math.min(64, Math.ceil(Math.abs(sweep) / (Math.PI / 18)))); @@ -44,7 +72,7 @@ export function segmentsOf(ent) { const pts = tessellate(ent); const segs = []; for (let i = 0; i < pts.length - 1; i++) segs.push([pts[i], pts[i + 1]]); - if (ent.type === 'circle' || ent.type === 'rect') segs.push([pts[pts.length - 1], pts[0]]); // 闭合轮廓 + if (ent.type === 'circle' || ent.type === 'rect' || ent.type === 'ellipse' || ent.type === 'slot' || (ent.type === 'spline' && ent.closed)) segs.push([pts[pts.length - 1], pts[0]]); // 闭合轮廓 return segs; } @@ -62,6 +90,22 @@ export function distToEntity(ent, p) { } case 'circle': return Math.abs(dist(p, ent.c) - ent.r); + case 'ellipse': { + const rot = ent.rotation || 0, dx = p[0] - ent.c[0], dy = p[1] - ent.c[1]; + const x = dx * Math.cos(rot) + dy * Math.sin(rot), y = -dx * Math.sin(rot) + dy * Math.cos(rot); + const q = Math.hypot(x / Math.max(ent.rx, 1e-6), y / Math.max(ent.ry, 1e-6)); + return Math.abs(q - 1) * Math.min(ent.rx || 1, ent.ry || 1); + } + case 'slot': { + const r = Math.max(1e-6, (ent.width || ent.w || 6) / 2), dx = ent.p2[0] - ent.p1[0], dy = ent.p2[1] - ent.p1[1], l2 = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((p[0] - ent.p1[0]) * dx + (p[1] - ent.p1[1]) * dy) / l2)); + return Math.abs(Math.hypot(p[0] - (ent.p1[0] + t * dx), p[1] - (ent.p1[1] + t * dy)) - r); + } + case 'spline': { + const pts = ent.points || []; let best = Infinity; + for (let i = 0; i < pts.length - 1; i++) best = Math.min(best, pointSegDist(p, pts[i], pts[i + 1])); + return best; + } case 'arc': { const d = dist(p, ent.c); const ang = Math.atan2(p[1] - ent.c[1], p[0] - ent.c[0]); @@ -95,11 +139,23 @@ export function snapPointsOf(ent) { case 'circle': pts.push({ pos: ent.c, kind: '圆心', key: ent.id + ':c', entId: ent.id }); break; + case 'ellipse': + pts.push({ pos: ent.c, kind: '中心', key: ent.id + ':c', entId: ent.id }); + break; + case 'slot': + pts.push({ pos: ent.p1, kind: '端点', key: ent.id + ':p0', entId: ent.id }, { pos: ent.p2, kind: '端点', key: ent.id + ':p1', entId: ent.id }); + break; + case 'spline': + (ent.points || []).forEach((p, i) => pts.push({ pos: p, kind: i === 0 || i === (ent.points.length - 1) ? '端点' : '控制点', key: ent.id + ':p' + i, entId: ent.id })); + break; case 'arc': pts.push({ pos: ent.c, kind: '圆心', key: ent.id + ':c', entId: ent.id }, { pos: [ent.c[0] + ent.r * Math.cos(ent.a0), ent.c[1] + ent.r * Math.sin(ent.a0)], kind: '端点', key: ent.id + ':p0', entId: ent.id }, { pos: [ent.c[0] + ent.r * Math.cos(ent.a1), ent.c[1] + ent.r * Math.sin(ent.a1)], kind: '端点', key: ent.id + ':p1', entId: ent.id }); break; + case 'ellipse': push(ent.c, 'c'); break; + case 'slot': push(ent.p1, 'p0'); push(ent.p2, 'p1'); break; + case 'spline': (ent.points || []).forEach((p, i) => push(p, 'p' + i)); break; } return pts; } @@ -159,7 +215,7 @@ export function applyConstraint(ent, cons) { } function resolvePointKey(ent, key) { - const m = String(key).match(/^(.+):(p0|p1|c)$/); + const m = String(key).match(/^(.+):(p\d+|c)$/); if (!m) return null; if (String(ent.id) !== m[1]) return null; switch (m[2]) { @@ -167,6 +223,8 @@ function resolvePointKey(ent, key) { case 'p1': return ent.p2 ?? null; case 'c': return ent.c ?? null; } + const i = Number(String(m[2]).slice(1)); + if (Number.isInteger(i) && Array.isArray(ent.points)) return ent.points[i] || null; return null; } @@ -194,7 +252,7 @@ export function dimValueOf(sk, dim) { } export function resolveDimPoint(sk, key) { - const m = key.match(/^(.+):(p0|p1|c)$/); + const m = key.match(/^(.+):(p\d+|c)$/); if (!m) return null; const e = sk.entities.find(x => x.id === m[1]); if (!e) return null; @@ -203,6 +261,8 @@ export function resolveDimPoint(sk, key) { case 'p1': return e.p2 ?? null; case 'c': return e.c ?? null; } + const i = Number(String(m[2]).slice(1)); + if (Number.isInteger(i) && Array.isArray(e.points)) return e.points[i] || null; return null; } diff --git a/js/sketchui.js b/js/sketchui.js index 0ab8cfe..1a73c76 100644 --- a/js/sketchui.js +++ b/js/sketchui.js @@ -118,7 +118,7 @@ export class SketchUI { const b = toWorld(t[i + 1][0], t[i + 1][1], 0, sk.plane, sk.offset, 1); arr.push(a[0], a[1], a[2], b[0], b[1], b[2]); } - if (e.type === 'circle') { + if (['circle', 'ellipse', 'slot'].includes(e.type) || (e.type === 'spline' && e.closed)) { const a = toWorld(t[0][0], t[0][1], 0, sk.plane, sk.offset, 1); const b = toWorld(t[t.length - 1][0], t[t.length - 1][1], 0, sk.plane, sk.offset, 1); arr.push(a[0], a[1], a[2], b[0], b[1], b[2]); @@ -183,6 +183,8 @@ export class SketchUI { if (d.type === 'arc' && d.c && d.r !== null && d.a0 !== null && d.a1 !== null) { segs.push(...arcSegs(d.c, d.r, d.a0, d.a1)); } + if (d.type === 'ellipse' && d.c && d.rx > 0 && d.ry > 0) segs.push(...ellipseSegs(d.c, d.rx, d.ry)); + if (d.type === 'spline' && d.points?.length > 1) for (let i = 0; i < d.points.length - 1; i++) segs.push([d.points[i], d.points[i + 1]]); const arr = []; for (const [a, b] of segs) { const wa = toWorld(a[0], a[1], 0, sk.plane, sk.offset, 1); @@ -218,6 +220,9 @@ export class SketchUI { case 'rect': this._rectTool(e, phase); break; case 'circle': this._circleTool(e, phase); break; case 'arc': this._arcTool(e, phase); break; + case 'ellipse': this._ellipseTool(e, phase); break; + case 'slot': this._slotTool(e, phase); break; + case 'spline': this._splineTool(e, phase); break; case 'trim': if (phase === 'down') this._trim(e); break; case 'dim': if (phase === 'down') this._dim(e); break; case 'h': case 'v': case 'eq': case 'co': if (phase === 'down') this._constrain(e); break; @@ -409,6 +414,46 @@ export class SketchUI { } } + _ellipseTool(e, phase) { + const uv = this.uv(e); if (!uv) return; + if (phase === 'down') { + if (!this.draft) this.draft = { type: 'ellipse', c: this.snap(uv).pos.slice(), rx: 0, ry: 0 }; + else { + const d = this.draft; d.rx = Math.abs(uv[0] - d.c[0]); d.ry = Math.abs(uv[1] - d.c[1]); + if (d.rx > 1e-4 && d.ry > 1e-4) { this.h.pushUndo(); this.doc.addEntity(this.sk.id, { type: 'ellipse', c: d.c.slice(), rx: d.rx, ry: d.ry, rotation: 0 }); } + this.draft = null; this.h.changed(); + } + this.render(); + } else if (phase === 'move' && this.draft) { this.draft.rx = Math.abs(uv[0] - this.draft.c[0]); this.draft.ry = Math.abs(uv[1] - this.draft.c[1]); this.render(); } + } + + _slotTool(e, phase) { + const uv = this.uv(e); if (!uv) return; + if (phase === 'down') { + if (!this.draft) this.draft = { type: 'slot', pts: [this.snap(uv).pos.slice()] }; + else if (this.draft.pts.length === 1) this.draft.pts.push(uv.slice()); + else { + const [a, b] = this.draft.pts, width = Math.max(0.1, 2 * distToLine(uv, a, b)); + this.h.pushUndo(); this.doc.addEntity(this.sk.id, { type: 'slot', p1: a, p2: b, width }); this.draft = null; this.h.changed(); + } + this.render(); + } else if (phase === 'move' && this.draft) { this.draft.previewPts = this.draft.pts.concat([uv]); this.render(); } + } + + _splineTool(e, phase) { + const uv = this.uv(e); if (!uv) return; + if (phase === 'down') { + if (!this.draft) this.draft = { type: 'spline', points: [this.snap(uv).pos.slice()] }; + else this.draft.points.push(uv.slice()); + this.render(); + } else if (phase === 'up' && this.draft && this.draft.points.length >= 3) { + const now = performance.now(); + if (now - this._lastClick < 300) { this.h.pushUndo(); this.doc.addEntity(this.sk.id, { type: 'spline', points: this.draft.points.map(p => p.slice()), closed: false }); this.draft = null; this.h.changed(); } + this._lastClick = now; + this.render(); + } + } + _trim(e) { const uv = this.uv(e); if (!uv) return; @@ -590,7 +635,7 @@ function segsOf(ent) { const t = tessellate(ent); const out = []; for (let i = 0; i < t.length - 1; i++) out.push([t[i], t[i + 1]]); - if (ent.type === 'circle') out.push([t[t.length - 1], t[0]]); + if (['circle', 'ellipse', 'slot'].includes(ent.type) || (ent.type === 'spline' && ent.closed)) out.push([t[t.length - 1], t[0]]); return out; } @@ -630,6 +675,18 @@ function arcSegs(c, r, a0, a1) { return segs; } +function ellipseSegs(c, rx, ry) { + const out = [], n = 32; let prev = [c[0] + rx, c[1]]; + for (let i = 1; i <= n; i++) { const a = i / n * Math.PI * 2, p = [c[0] + rx * Math.cos(a), c[1] + ry * Math.sin(a)]; out.push([prev, p]); prev = p; } + return out; +} + +function distToLine(p, a, b) { + const dx = b[0] - a[0], dy = b[1] - a[1], l2 = dx * dx + dy * dy || 1; + const t = Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2)); + return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); +} + function circumCenter(a, b, c) { const d = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1])); if (Math.abs(d) < 1e-9) return null; @@ -639,7 +696,7 @@ function circumCenter(a, b, c) { } function entName(e) { - return { line: '直线', rect: '矩形', circle: '圆', arc: '圆弧', centerline: '中心线' }[e.type] || e.type; + return { line: '直线', rect: '矩形', circle: '圆', arc: '圆弧', ellipse: '椭圆', slot: '长圆槽', spline: '样条', centerline: '中心线' }[e.type] || e.type; } function consName(t) { diff --git a/js/tree.js b/js/tree.js index a2a24c0..19f3426 100644 --- a/js/tree.js +++ b/js/tree.js @@ -34,6 +34,19 @@ export class TreeUI { planesBox.appendChild(pn); } this.el.appendChild(planesBox); + // 用户参考几何 + const refs = doc.references || []; + const refsBox = this._box(`参考几何 (${refs.length})`, true); + for (const ref of refs) { + const icon = { plane: '▱', axis: '╱', point: '•', csys: '⌖' }[ref.type] || '◇'; + refsBox.appendChild(this._node({ + icon, name: ref.name || `${ref.type} #${ref.id}`, cls: 'ref', + data: { menu: { type: 'reference', id: ref.id, refType: ref.type } }, + onClick: () => this.h.onSelectReference && this.h.onSelectReference(ref.id), + title: `${ref.type} · #${ref.id}`, + })); + } + this.el.appendChild(refsBox); // 草图 const skBox = this._box(`草图 (${doc.sketches.length})`, true); for (const sk of doc.sketches) { @@ -51,7 +64,7 @@ export class TreeUI { const feBox = this._box(`特征 (${doc.features.length})`, true); for (const f of doc.features) { const cls = 'feat' + (extra.selFeatureId === f.id ? ' sel' : '') + (f.error ? ' bad' : ''); - const icon = { extrude: '▣', cut: '▢', revolve: '◎', cylinder: '◉', cylcut: '◌' }[f.type] || '▣'; + const icon = { extrude: '▣', cut: '▢', revolve: '◎', cylinder: '◉', cylcut: '◌', sweep: '∿', loft: '⌑', rib: '◩', chamfer: '◪', fillet: '◜' }[f.type] || '▣'; const pn = this._node({ icon, name: f.name + (f.error ? ' ⚠' : ''), cls, data: { menu: { type: 'feature', id: f.id, sketchId: f.sketchId } }, @@ -110,6 +123,9 @@ export class TreeUI { if (menu.sketchId) add('编辑草图', () => this.h.onEditSketch(menu.sketchId)); add('编辑特征参数', () => this.h.onEditFeature(menu.id)); add('删除特征', () => this.h.onDeleteFeature(menu.id), true); + } else if (menu.type === 'reference') { + if (menu.refType === 'plane' && this.h.onNewSketchReference) add('在此参考面新建草图', () => this.h.onNewSketchReference(menu.id)); + if (this.h.onDeleteReference) add('删除参考几何', () => this.h.onDeleteReference(menu.id), true); } m.style.left = x + 'px'; m.style.top = y + 'px'; diff --git a/js/viewport.js b/js/viewport.js index 9c286d6..8296a62 100644 --- a/js/viewport.js +++ b/js/viewport.js @@ -55,6 +55,7 @@ export class Viewport { // 地面网格(3D 模式) this.groundGrid = new THREE.GridHelper(500, 50, 0x4a5568, 0x2f3640); this.groundGrid.position.y = 0; + this.groundGrid.userData.workspaceSize = 500; this.groundGrid.visible = true; this.scene.add(this.groundGrid); @@ -81,6 +82,30 @@ export class Viewport { this.css.setSize(w, h); } + /** + * 按真实工程尺寸把当前实体完整放入视口。 + * 这里只调整相机,不修改模型坐标、单位或文档参数。 + */ + fitToModel(padding = 1.35) { + const box = new THREE.Box3().setFromObject(this.bodyGroup); + if (box.isEmpty()) return null; + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const radius = Math.max(size.length() * 0.5, 1); + const direction = this.camera.position.clone().sub(this.controls.target); + if (direction.lengthSq() < 1e-8) direction.set(1, 0.8, 1); + direction.normalize(); + const halfFov = THREE.MathUtils.degToRad(this.camera.fov) * 0.5; + const distance = Math.max(80, radius * padding / Math.tan(halfFov)); + this.controls.target.copy(center); + this.camera.position.copy(center).addScaledVector(direction, distance); + this.camera.near = Math.max(0.01, distance / 1000); + this.camera.far = Math.max(8000, distance * 100); + this.camera.updateProjectionMatrix(); + this.controls.update(); + return { center: center.toArray(), dimensions: size.toArray(), distance }; + } + animate() { requestAnimationFrame(() => this.animate()); this.controls.update(); @@ -92,8 +117,8 @@ export class Viewport { setView(name, sketch) { const dist = this.camera.position.distanceTo(this.controls.target) || 200; const views = { - front: { pos: [0, dist, 0], up: [0, 0, 1] }, - top: { pos: [0, 0, dist], up: [0, 1, 0] }, + front: { pos: [0, 0, dist], up: [0, 1, 0] }, + top: { pos: [0, dist, 0], up: [0, 0, 1] }, right: { pos: [dist, 0, 0], up: [0, 1, 0] }, iso: { pos: [dist * 0.8, dist * 0.8, dist * 0.8], up: [0, 1, 0] }, }; @@ -104,11 +129,12 @@ export class Viewport { } lookAtSketch(sk) { - const nrm = PLANES[sk.plane].normal; + const def = planeDef(sk.plane); + const nrm = def.normal; const p = toWorld(0, 0, 0, sk.plane, sk.offset, 1); const dist = this.camera.position.distanceTo(this.controls.target) || 180; this.camera.position.set(p[0] + nrm[0] * dist, p[1] + nrm[1] * dist, p[2] + nrm[2] * dist); - this.camera.up.set(...PLANES[sk.plane].vAxis); + this.camera.up.set(...def.vAxis); this.controls.target.set(p[0], p[1], p[2]); this.controls.update(); } @@ -193,15 +219,15 @@ export class Viewport { geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(c.flat()), 3)); const mat = new THREE.MeshBasicMaterial({ color: d.color, transparent: true, opacity: 0.06, side: THREE.DoubleSide, depthWrite: false }); const mesh = new THREE.Mesh(geo, mat); - if (d.key === 'front') { mesh.rotation.x = -Math.PI / 2; } // XZ 平面(法向 +Y) - else if (d.key === 'top') { } // XY 平面(法向 +Z) + if (d.key === 'front') { } // XY 平面(法向 +Z) + else if (d.key === 'top') { mesh.rotation.x = Math.PI / 2; } // XZ 平面(法向 +Y) else if (d.key === 'right') { mesh.rotation.y = Math.PI / 2; } // YZ 平面(法向 +X) this.planeGroup.add(mesh); // 标签 const el = document.createElement('div'); el.className = 'minisw-label plane'; el.textContent = PLANES[d.key].name; - const pos = d.key === 'front' ? [0, 0, 150] : d.key === 'top' ? [150, 150, 0] : [0, 150, 0]; + const pos = d.key === 'front' ? [150, 150, 0] : d.key === 'top' ? [150, 0, 150] : [0, 150, 150]; const obj = new CSS2DObject(el); obj.position.set(...pos); this.planeGroup.add(obj); @@ -210,6 +236,28 @@ export class Viewport { setPlanesVisible(v) { this.planeGroup.visible = v; } + /** 让参照网格和基准面覆盖当前实体,避免大尺寸零件看起来“跑出地图”。 */ + updateWorkspaceGuides() { + const box = new THREE.Box3().setFromObject(this.bodyGroup); + if (box.isEmpty()) return; + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const required = Math.max(500, Math.ceil(Math.max(size.x, size.y, size.z, 1) * 1.5 / 50) * 50); + const current = Number(this.groundGrid.userData.workspaceSize) || 500; + if (required !== current) { + this.scene.remove(this.groundGrid); + this.groundGrid.geometry?.dispose(); + if (Array.isArray(this.groundGrid.material)) this.groundGrid.material.forEach(m => m.dispose()); + else this.groundGrid.material?.dispose(); + this.groundGrid = new THREE.GridHelper(required, Math.min(2000, Math.max(50, Math.round(required / 10))), 0x4a5568, 0x2f3640); + this.groundGrid.userData.workspaceSize = required; + this.scene.add(this.groundGrid); + } + this.groundGrid.position.set(center.x, 0, center.z); + const guideScale = required / 500; + this.planeGroup.scale.setScalar(guideScale); + } + // ---------- 实体渲染 ---------- updateBody(meshData) { this._disposeGroup(this.bodyGroup); @@ -220,6 +268,23 @@ export class Viewport { const mat = new THREE.MeshStandardMaterial({ color: 0xb7c4d6, roughness: 0.6, metalness: 0.05, side: THREE.DoubleSide, flatShading: true }); const mesh = new THREE.Mesh(geo, mat); this.bodyGroup.add(mesh); + this.updateWorkspaceGuides(); + } + + updateComponents(components = []) { + this._disposeGroup(this.bodyGroup); + const palette = [0xb7c4d6, 0x86b8e8, 0xd7a96b, 0x8fd19e]; + for (let i = 0; i < components.length; i++) { + const c = components[i], md = c.renderMesh || c.mesh; + if (!c.visible || !md) continue; + const color = c.color ?? palette[i % palette.length]; + const geo = meshToGeo(md, color); + const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.58, metalness: 0.08, side: THREE.DoubleSide, flatShading: true }); + const mesh = new THREE.Mesh(geo, mat); + mesh.userData.componentId = c.id; + this.bodyGroup.add(mesh); + } + this.updateWorkspaceGuides(); } _disposeGroup(group) { @@ -253,6 +318,31 @@ export class Viewport { return hits[0].object.userData.fid; } + /** 从当前可见实体表面附近拾取一条真实拓扑棱边。 */ + pickBodyEdge(x, y, edges, maxPixels = 18) { + const bodyMesh = this.bodyGroup.children.find(o => o.isMesh); + if (!bodyMesh || !Array.isArray(edges) || !edges.length) return null; + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(this.toNDC(x, y), this.camera); + const hit = raycaster.intersectObject(bodyMesh, false)[0]; + if (!hit) return null; + const triangleIndex = Number.isInteger(hit.faceIndex) ? hit.faceIndex * 3 : null; + const visibleCandidates = triangleIndex == null ? edges : edges.filter(edge => edge.triangleIndices?.includes(triangleIndex)); + const candidates = visibleCandidates.length ? visibleCandidates : edges; + const rect = this.container.getBoundingClientRect(); + const project = p => { + const v = new THREE.Vector3(...p).project(this.camera); + return [rect.left + (v.x + 1) * rect.width / 2, rect.top + (1 - v.y) * rect.height / 2]; + }; + let best = null, bestDistance = Infinity; + for (const edge of candidates) { + const a = project(edge.a), b = project(edge.b); + const distance = pointSegmentDistance2D([x, y], a, b); + if (distance < bestDistance) { best = edge; bestDistance = distance; } + } + return bestDistance <= maxPixels ? best : null; + } + buildPickTargets(deltas) { this.clearPickTargets(); for (const [fid, md] of Object.entries(deltas || {})) { @@ -291,7 +381,7 @@ export class Viewport { screenToSketchUV(x, y, sk) { const ray = new THREE.Raycaster(); ray.setFromCamera(this.toNDC(x, y), this.camera); - const nrm = new THREE.Vector3(...PLANES[sk.plane].normal); + const nrm = new THREE.Vector3(...planeDef(sk.plane).normal); const origin = new THREE.Vector3(...toWorld(0, 0, 0, sk.plane, sk.offset, 1)); const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(nrm, origin); const hit = new THREE.Vector3(); @@ -330,6 +420,18 @@ export class Viewport { } } +function planeDef(plane) { + if (!plane || typeof plane === 'string') return PLANES[plane] || PLANES.front; + return { normal: plane.normal, uAxis: plane.xAxis, vAxis: plane.yAxis }; +} + +function pointSegmentDistance2D(p, a, b) { + const dx = b[0] - a[0], dy = b[1] - a[1]; + const l2 = dx * dx + dy * dy; + const t = l2 > 1e-12 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2)) : 0; + return Math.hypot(p[0] - (a[0] + dx * t), p[1] - (a[1] + dy * t)); +} + function meshToGeo(md, color) { const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(md.verts.slice(), 3)); diff --git a/tests/loader.mjs b/tests/loader.mjs index 718ba96..2a80ace 100644 --- a/tests/loader.mjs +++ b/tests/loader.mjs @@ -1,14 +1,14 @@ -// loader.mjs —— 把浏览器裸导入映射到本地 node_modules(仅测试用) +// loader.mjs —— 把浏览器裸导入映射到仓库内 vendor 依赖(仅测试用) import { pathToFileURL } from 'node:url'; -import { createRequire } from 'node:module'; -const require = createRequire(import.meta.url); +import { dirname, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; -const NM = 'c:/Users/32919/.trae-cn/work/6a78857c0515f1e48ec3076f/node_modules'; +const ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), '..'); const map = { - 'three': pathToFileURL(NM + '/three/build/three.module.js').href, - 'three/addons/controls/OrbitControls.js': pathToFileURL(require.resolve('three/examples/jsm/controls/OrbitControls.js')).href, - 'three/addons/renderers/CSS2DRenderer.js': pathToFileURL(require.resolve('three/examples/jsm/renderers/CSS2DRenderer.js')).href, - 'manifold-3d': pathToFileURL(NM + '/manifold-3d/manifold.js').href, + 'three': pathToFileURL(resolvePath(ROOT, 'vendor/three/three.module.js')).href, + 'three/addons/controls/OrbitControls.js': pathToFileURL(resolvePath(ROOT, 'vendor/three/examples/jsm/controls/OrbitControls.js')).href, + 'three/addons/renderers/CSS2DRenderer.js': pathToFileURL(resolvePath(ROOT, 'vendor/three/examples/jsm/renderers/CSS2DRenderer.js')).href, + 'manifold-3d': pathToFileURL(resolvePath(ROOT, 'vendor/manifold/manifold.js')).href, }; export async function resolve(specifier, context, next) { diff --git a/tests/test.agent-crud.mjs b/tests/test.agent-crud.mjs new file mode 100644 index 0000000..214f004 --- /dev/null +++ b/tests/test.agent-crud.mjs @@ -0,0 +1,255 @@ +import { initGeometry } from '../js/geometry.js'; +import { Doc } from '../js/doc.js'; +import { + parseNaturalLanguage, + executeOps, + expandOps, + classifyIntent, + interpretAgentRequest, + applyAgentMutation, + repairOps, + validateOps, + validateOpsAgainstDocument, + inspectDocument, + describeInspection, + buildVisualFeedback, + reviewGeneratedModel, + decideLocalRequest, + runLLM, + runAgentChat, + repairLLMPlan, +} from '../js/agent.js'; + +await initGeometry(() => {}); +let pass = 0; +const ok = (value, message) => { + if (!value) throw new Error(message); + pass++; +}; + +const doc = new Doc(); +executeOps(doc, parseNaturalLanguage('做一个120×80×12的底板,加一条加强筋,厚度8mm,筋高50mm')); +doc.rebuild(); +ok(doc.features.length === 2 && !doc.features.some(f => f.error), '基线实体建立'); + +const observation = inspectDocument(doc); +ok(observation.hasBody && observation.status === 'NoError' && observation.dimensions.every((v, i) => Math.abs(v - [120, 80, 56][i]) < 1e-6), '几何自观察读取尺寸与实体状态'); +ok(describeInspection(observation).includes('体积') && describeInspection(observation).includes('加强筋'), '几何自观察生成可读描述'); + +const query = interpretAgentRequest('查询当前模型有哪些特征和尺寸', doc); +ok(query.kind === 'query' && query.reply.includes('120.00×80.00×56.00'), '查询意图返回尺寸与特征'); +const visionQuery = interpretAgentRequest('你能看到什么?请描述当前模型', doc); +ok(visionQuery.kind === 'query' && visionQuery.reply.includes('我观察到') && visionQuery.reply.includes('体积'), '自然语言自观察查询'); +const localExact = decideLocalRequest('做一个120×80×12的底板', doc); +ok(localExact?.kind === 'ops' && localExact.ops.some(op => op.op === 'extrude'), '完整尺寸请求由本地精确闸门直接执行'); +ok(!decideLocalRequest('椅子', doc) && !decideLocalRequest('做一个锥齿轮', doc), '对象或关键参数模糊时本地拒绝擅自补全并转交 LLM'); + +const update = interpretAgentRequest('把加强筋厚度改为6mm,筋高改为40mm', doc); +ok(update.kind === 'update' && update.patch.thickness === 6 && update.patch.extent === 40, '修改意图解析参数'); +applyAgentMutation(doc, update); +doc.rebuild(); +ok(doc.features.at(-1).params.thickness === 6 && doc.features.at(-1).params.extent === 40 && !doc.features.some(f => f.error), '修改后全树重建'); + +const add = interpretAgentRequest('在当前零件上添加一个直径20mm的通孔', doc); +ok(add.kind === 'ops' && add.intent === 'add' && add.ops.at(-1).op === 'cut' && !validateOps(add.ops), '追加通孔生成局部操作'); + +const del = interpretAgentRequest('删除最后一个特征', doc); +ok(del.kind === 'delete' && del.featureId === doc.features.at(-1).id, '删除意图定位最后特征'); +applyAgentMutation(doc, del); +doc.rebuild(); +ok(!doc.features.some(f => f.error), '删除后全树重建'); + +const repaired = repairOps( + [{ op: 'sketch', plane: 'front', name: '草图' }, { op: 'extrude', depth: 10, name: '主体' }, { op: 'fillet_edge', radius: 5, name: '失败圆角' }], + [{ type: 'fillet', name: '失败圆角', error: '仅支持凸实体' }], +); +ok(repaired.removed.length === 1 && repaired.ops.length === 2, '错误操作自动修正计划'); + +const llmCrudDoc = new Doc(); +executeOps(llmCrudDoc, [{ op: 'cylinder', r: 12, h: 20, name: '主体' }, { op: 'cylinder', r: 6, h: 8, tz: 14, name: '凸台' }]); +llmCrudDoc.rebuild(); +const llmTargetId = llmCrudDoc.features.at(-1).id; +ok(!validateOps([{ op: 'update_feature', featureId: llmTargetId, patch: { h: 12 } }]) && !validateOps([{ op: 'delete_feature', featureId: llmTargetId }]), 'LLM 修改/删除 DSL 可校验'); +executeOps(llmCrudDoc, [{ op: 'update_feature', featureId: llmTargetId, patch: { h: 12 } }]); +ok(llmCrudDoc.featureById(llmTargetId).params.h === 12, 'LLM 修改操作作用于稳定特征 ID'); +executeOps(llmCrudDoc, [{ op: 'delete_feature', featureId: llmTargetId }]); +ok(!llmCrudDoc.featureById(llmTargetId) && llmCrudDoc.features.length === 1, 'LLM 删除操作作用于稳定特征 ID'); +const queryResult = executeOps(llmCrudDoc, [{ op: 'query_document', answer: '当前有一个主体特征。' }]); +ok(queryResult.answers[0].includes('一个主体'), 'LLM 查询操作返回最终回答'); +ok(validateOpsAgainstDocument(llmCrudDoc, [{ op: 'delete_feature', featureId: 999999 }]).includes('不存在'), '执行前检查拦截失效稳定特征 ID'); +ok(validateOpsAgainstDocument(new Doc(), [{ op: 'assembly_add_component', id: 'part' }]).includes('前置零件实体'), '执行前检查拦截缺少实体的装配计划'); + +const originalFetch = globalThis.fetch; +let capturedRequest = null; +globalThis.fetch = async (_url, init) => { + capturedRequest = JSON.parse(init.body); + return { ok: true, json: async () => ({ choices: [{ message: { content: '[{"op":"cylinder","r":10,"h":20}]' } }] }) }; +}; +try { + const fastOps = await runLLM('做一个圆柱', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'fast-model', speed: 'fast', timeoutMs: 5000 }, { + modelState: '{"hasBody":true}', + history: Array.from({ length: 8 }, (_, i) => ({ role: i % 2 ? 'assistant' : 'user', content: String(i) })), + }); + ok(fastOps.length === 1 && fastOps[0].op === 'cylinder', '快速 LLM 输出可直接校验'); + ok(capturedRequest.temperature === undefined && capturedRequest.max_tokens === undefined && capturedRequest.thinking === undefined, 'LLM 请求不注入额外输出和推理约束'); + ok(capturedRequest.response_format === undefined, 'LLM 请求不强制响应格式'); + ok(capturedRequest.messages.length === 11, '统一 Agent 不截断历史上下文'); + + let toolRound = 0; + globalThis.fetch = async (_url, init) => { + const body = JSON.parse(init.body); + toolRound++; + if (toolRound === 1) return { ok: true, json: async () => ({ choices: [{ message: { content: null, tool_calls: [{ id: 'call-inspect', type: 'function', function: { name: 'inspect_model', arguments: '{}' } }] } }] }) }; + return { ok: true, json: async () => ({ choices: [{ message: { content: '已读取模型并完成判断。' } }] }) }; + }; + const chatResult = await runAgentChat('查看当前模型', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'tool-model' }, { + toolHandlers: { inspect_model: async () => ({ hasBody: true, features: [] }) }, + }); + ok(chatResult.reply.includes('已读取模型') && toolRound === 2, '聊天 Agent 能自主调用工具并继续对话'); + + let jsonFallbackCalls = 0; + globalThis.fetch = async () => { + jsonFallbackCalls++; + if (jsonFallbackCalls === 1) return { ok: false, status: 400, json: async () => ({ error: { message: 'response_format is not supported' } }) }; + return { ok: true, json: async () => ({ choices: [{ message: { content: '[{"op":"cylinder","r":8,"h":12}]' } }] }) }; + }; + const fallbackOps = await runLLM('做一个小圆柱', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'compatible-model', speed: 'fast', timeoutMs: 5000 }); + ok(jsonFallbackCalls === 2 && fallbackOps[0].op === 'cylinder', '不支持结构化输出的兼容接口会自动降级且仍能完成计划'); + + globalThis.fetch = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: `[{"op":"delete_feature","featureId":${llmCrudDoc.features[0].id}}]` } }] }) }); + const deleteOps = await runLLM('删除', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'fast-model', speed: 'fast', timeoutMs: 5000 }, { modelState: JSON.stringify({ hasBody: true, selectedFeatureId: llmCrudDoc.features[0].id, lastFeatureId: llmCrudDoc.features[0].id }) }); + ok(deleteOps[0].op === 'delete_feature' && deleteOps[0].featureId === llmCrudDoc.features[0].id, 'LLM 模式理解裸“删除”并使用当前稳定 ID'); + + let expertDeleteCalls = 0; + globalThis.fetch = async () => { + expertDeleteCalls++; + const content = `{"ops":[{"op":"delete_feature","featureId":${llmCrudDoc.features[0].id}}]}`; + return { ok: true, json: async () => ({ choices: [{ message: { content } }] }) }; + }; + const expertDeleteOps = await runLLM('删除', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'precise-model', speed: 'precise', timeoutMs: 5000 }, { modelState: JSON.stringify({ hasBody: true, selectedFeatureId: llmCrudDoc.features[0].id, lastFeatureId: llmCrudDoc.features[0].id }) }); + ok(expertDeleteCalls === 1 && expertDeleteOps.agentMeta.calls === 1 && expertDeleteOps.agentMeta.mode === 'unified', '模糊删除请求交给统一 Agent 一次性结合模型状态判断'); + + let preciseCalls = 0; + globalThis.fetch = async () => { + preciseCalls++; + const content = '{"ops":[{"op":"sketch","plane":"front"},{"op":"rect","cx":0,"cy":0,"w":80,"h":50},{"op":"extrude","depth":10},{"op":"drawing_generate","format":"A4"}]}'; + return { ok: true, json: async () => ({ choices: [{ message: { content } }] }) }; + }; + const preciseOps = await runLLM('新建一个80×50×10的底座并生成A4工程图', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'precise-model', speed: 'precise', timeoutMs: 5000 }); + ok(preciseCalls === 1, '统一 Agent 一次调用完成复杂建模与工程图意图'); + ok(preciseOps.some(op => op.op === 'extrude') && preciseOps.some(op => op.op === 'drawing_generate'), '统一规划覆盖建模与工程图意图'); + + let malformedCalls = 0; + globalThis.fetch = async () => { + malformedCalls++; + const responses = [ + '{"ops":[{"op":"sketch","plane":"front"},{"op":"chamfer_rect","cx":0,"cy":0,"w":100,"h":80,"chamfer":5},{"op":"extrude","depth":20}]}', + '{"ops":[{"op":"sketch","plane":"front"},{"op":"chamfer_rect","cx":0,"cy":0,"w":100,"h":80,"chamfer":5},{"op":"extrude","depth":20}]}', + ]; + return { ok: true, json: async () => ({ choices: [{ message: { content: responses[malformedCalls - 1] } }] }) }; + }; + const malformedRecovered = await runLLM('做一个100×80×20的矩形板,四边倒角5mm', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'precise-model', speed: 'precise', timeoutMs: 5000 }); + ok(malformedCalls === 1, '统一 Agent 直接输出可执行 DSL,无需额外意图阶段'); + ok(malformedRecovered.some(op => op.op === 'extrude'), '修复意图后继续生成可执行 DSL'); + + let emptyContentCalls = 0; + globalThis.fetch = async () => { + emptyContentCalls++; + if (emptyContentCalls === 1) return { ok: true, json: async () => ({ choices: [{ finish_reason: 'stop', message: { content: null, reasoning_content: '正在分析尺寸与空心结构' } }] }) }; + const content = '{"ops":[{"op":"cylinder","r":30,"h":50},{"op":"cylcut","r":20,"h":52}]}' + return { ok: true, json: async () => ({ choices: [{ message: { content } }] }) }; + }; + const emptyRecovered = await runLLM('空心圆柱,外径60,内径40,高50', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'reasoning-model', speed: 'precise', timeoutMs: 5000 }); + ok(emptyContentCalls === 2, '统一 Agent 空内容只进行一次紧凑重试'); + ok(emptyRecovered.some(op => op.op === 'cylcut'), '空内容恢复后继续完成空心圆柱计划'); + + const gearRequests = []; + globalThis.fetch = async (_url, init) => { + gearRequests.push(JSON.parse(init.body)); + return { ok: true, json: async () => ({ choices: [{ message: { content: '{"ops":[{"op":"spur_gear","module":2,"teeth":20,"width":20,"pressureAngle":20,"boreDiameter":10,"name":"直齿轮"}]}' } }] }) }; + }; + const gearOps = await runLLM('直齿轮', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'reasoning-model', speed: 'precise', timeoutMs: 5000 }); + ok(gearRequests.length === 1 && gearOps.agentMeta.calls === 1 && gearOps.agentMeta.mode === 'unified', '模糊齿轮请求交给统一 Agent 一次性补全并规划'); + ok(gearOps.length === 1 && gearOps[0].op === 'spur_gear' && gearOps[0].teeth === 20 && gearOps[0].boreDiameter === 10, '直齿轮使用单个参数化高级指令,并为未指定孔径采用合理默认值'); + const gearDoc = new Doc(); + executeOps(gearDoc, gearOps); + gearDoc.rebuild(); + ok(!gearDoc.features.some(feature => feature.error) && Math.abs(gearDoc.bbox.max[0] - gearDoc.bbox.min[0] - 44) < 0.1 && Math.abs(gearDoc.bbox.max[2] - gearDoc.bbox.min[2] - 20) < 0.1, '参数化编译器生成外径44、齿宽20的有效渐开线直齿轮'); + + const bevelOps = [{ op: 'bevel_gear', module: 2, teeth: 20, width: 8, pressureAngle: 20, shaftAngle: 90, pitchConeAngle: 45, boreDiameter: 10, name: '锥齿轮' }]; + ok(!validateOps(bevelOps) && expandOps(bevelOps)[0].op === 'loft', '锥齿轮使用独立 bevel_gear,并展开为锥形放样'); + const bevelDoc = new Doc(); + executeOps(bevelDoc, bevelOps); + bevelDoc.rebuild(); + ok(!bevelDoc.features.some(feature => feature.error) && Math.abs(bevelDoc.bbox.max[2] - bevelDoc.bbox.min[2] - 8) < 0.1, '锥齿轮放样实体可重建且保持齿宽'); + let chairCalls = 0; + globalThis.fetch = async () => { + chairCalls++; + const content = '{"ops":[{"op":"cylinder","r":250,"h":40,"tz":450,"name":"默认座面"}]}'; + return { ok: true, json: async () => ({ choices: [{ finish_reason: content ? 'stop' : 'length', message: { content } }] }) }; + }; + const chairOps = await runLLM('椅子', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'reasoning-model', speed: 'precise', timeoutMs: 5000 }); + ok(chairCalls === 1 && chairOps.agentMeta.calls === 1 && chairOps[0].op === 'cylinder', '模糊对象交给统一 Agent 补充默认工程假设后完成 DSL'); + + globalThis.fetch = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: '[{"op":"cylinder","r":24,"h":10,"axis":undefined},{"op":"pattern_circular","count":20,"cx":NaN,"cy":0,}]' } }] }) }); + const jsLikeOps = await runLLM('做一个圆柱', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'reasoning-model', speed: 'fast', timeoutMs: 5000 }); + ok(jsLikeOps[0].axis === null && jsLikeOps[1].cx === null, 'DSL 自动修复 undefined、NaN 和尾逗号等非 JSON 值'); + + let expressionCalls = 0; + globalThis.fetch = async () => { + expressionCalls++; + const content = expressionCalls === 1 + ? '[{"op":"cylinder","r":s.p/(2r_p),"h":10}]' + : '[{"op":"cylinder","r":24,"h":10}]'; + return { ok: true, json: async () => ({ choices: [{ message: { content } }] }) }; + }; + const expressionOps = await runLLM('做一个圆柱', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'reasoning-model', speed: 'fast', timeoutMs: 5000 }); + ok(expressionCalls === 2 && expressionOps[0].r === 24, '未加引号的工程表达式会被拦截并触发 DSL 重规划'); + + let loneMinusCalls = 0; + globalThis.fetch = async () => { + loneMinusCalls++; + const content = loneMinusCalls === 1 + ? '[{"op":"cylinder","r":-,"h":10}]' + : '[{"op":"cylinder","r":24,"h":10}]'; + return { ok: true, json: async () => ({ choices: [{ message: { content } }] }) }; + }; + const loneMinusOps = await runLLM('做一个圆柱', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'reasoning-model', speed: 'fast', timeoutMs: 5000 }); + ok(loneMinusCalls === 2 && loneMinusOps[0].r === 24, '孤立负号会被拦截并触发 DSL 重规划'); + + const streamEvents = []; + globalThis.fetch = async () => { + const frames = [ + { choices: [{ delta: { content: '{"ops":[' } }] }, + { choices: [{ delta: { content: '{"op":"cylinder","r":24,"h":10}' } }] }, + { choices: [{ delta: { content: ']}' } }] }, + ]; + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + for (const frame of frames) controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`)); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }; + const streamedOps = await runLLM('做一个圆柱', { baseURL: 'https://api.deepseek.com/v1', apiKey: 'test', model: 'deepseek-chat', speed: 'fast', timeoutMs: 5000 }, { onStream: event => streamEvents.push(event) }); + ok(streamedOps[0].op === 'cylinder' && streamEvents.some(event => !event.done && event.accumulated.includes('cylinder')) && streamEvents.at(-1).done, 'DeepSeek SSE 流式输出实时转发调试窗口并保持最终 DSL 解析'); + + globalThis.fetch = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: '[{"op":"sketch","plane":"front"},{"op":"rect","cx":0,"cy":0,"w":80,"h":50},{"op":"extrude","depth":10},{"op":"drawing_generate","format":"A4"}]' } }] }) }); + const visualFeedback = buildVisualFeedback(observation); + ok(visualFeedback.observer.includes('local geometry') && visualFeedback.placement.dimensions[2] === 56, '视觉模块生成模型位置与尺寸反馈'); + globalThis.fetch = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: '{"pass":true,"severity":"info","summary":"几何结构与用户意图一致","findings":[],"correction":""}' } }] }) }); + const reviewed = await reviewGeneratedModel('做一个底板并加加强筋', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'review-model', speed: 'precise', timeoutMs: 5000 }, {}, [{ op: 'extrude' }, { op: 'rib' }], observation); + ok(reviewed.pass && reviewed.calls === 1 && reviewed.feedback.featureTree.length === 2, '生成后由机械制图专家完成一次视觉复核'); + + globalThis.fetch = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: '[{"op":"sketch","plane":"front"},{"op":"rect","cx":0,"cy":0,"w":80,"h":50},{"op":"extrude","depth":10},{"op":"drawing_generate","format":"A4"}]' } }] }) }); + const correctedOps = await repairLLMPlan('新建一个底座并生成工程图', { baseURL: 'https://example.invalid/v1', apiKey: 'test', model: 'repair-model', speed: 'fast', timeoutMs: 5000 }, {}, [{ op: 'drawing_generate' }], '生成工程图前需要当前零件实体'); + ok(correctedOps.some(op => op.op === 'extrude'), '执行错误会回传 LLM 并补全前置实体'); + ok(correctedOps.agentMeta.calls === 1 && correctedOps.agentMeta.trace[0].stage === 'execution_repair', 'LLM 纠错计划记录调用轨迹'); +} finally { + globalThis.fetch = originalFetch; +} + +console.log(`agent CRUD/vision ${pass}/${pass} passed`); diff --git a/tests/test.assembly.mjs b/tests/test.assembly.mjs new file mode 100644 index 0000000..2538a44 --- /dev/null +++ b/tests/test.assembly.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { createAssembly, addComponent, addMate, solveMates, checkInterference, setExplode, assemblyFromJSON } from '../js/features/assembly.js'; + +const cube = (x = 10) => ({ verts: new Float32Array([-x,-x,-x, x,-x,-x, x,x,-x, -x,x,-x, -x,-x,x, x,-x,x, x,x,x, -x,x,x]), tris: new Uint32Array([0,1,2,0,2,3,4,6,5,4,7,6,0,4,5,0,5,1,1,5,6,1,6,2,2,6,7,2,7,3,4,0,3,4,3,7]) }); +const a = createAssembly('滑块机构'); +const base = addComponent(a, { id: 'base', name: '底座', mesh: cube(), fixed: true }); +const slide = addComponent(a, { id: 'slide', name: '滑块', mesh: cube(), transform: { position: [30, 0, 0] } }); +addMate(a, { type: 'coincident', refA: { componentId: 'base', position: [0, 0, 0] }, refB: { componentId: 'slide', position: [0, 0, 0] } }); +const report = solveMates(a); +assert.equal(report.at(-1).status, 'solved'); +assert.deepEqual(slide.transform.position, [0, 0, 0]); +assert.equal(checkInterference(base, slide).interfering, true); +setExplode(a, { progress: 1, vectors: { slide: [0, 0, 25] } }); +assert.equal(slide.transform.position[2], 25); +assert.equal(assemblyFromJSON(JSON.parse(JSON.stringify(a))).components.length, 2); +console.log('assembly 5/5 passed'); diff --git a/tests/test.chamfer.mjs b/tests/test.chamfer.mjs new file mode 100644 index 0000000..d9f8a92 --- /dev/null +++ b/tests/test.chamfer.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { extractTopology, makeStableEdgeRef, resolveEdgeRef, buildChamferMesh } from '../js/features/chamfer.js'; +import { initGeometry, manifoldFromMeshData } from '../js/geometry.js'; + +await initGeometry(); + +function cube(size = 20) { + const v = [-10,-10,-10, 10,-10,-10, 10,10,-10, -10,10,-10, -10,-10,10, 10,-10,10, 10,10,10, -10,10,10]; + // 朝外三角面。 + const t = [0,2,1, 0,3,2, 4,5,6, 4,6,7, 0,1,5, 0,5,4, 1,2,6, 1,6,5, 2,3,7, 2,7,6, 3,0,4, 3,4,7]; + return { verts: new Float32Array(v.map(x => x * size / 20)), tris: new Uint32Array(t) }; +} +function closed(mesh, tol = 1e-6) { extractTopology(mesh, tol); return true; } +function hasNoDegenerate(mesh, tol = 1e-7) { for (let i = 0; i < mesh.tris.length; i += 3) { const p = j => [mesh.verts[mesh.tris[i+j]*3], mesh.verts[mesh.tris[i+j]*3+1], mesh.verts[mesh.tris[i+j]*3+2]]; const a=p(0),b=p(1),c=p(2); const area2=Math.hypot((b[1]-a[1])*(c[2]-a[2])-(b[2]-a[2])*(c[1]-a[1]),(b[2]-a[2])*(c[0]-a[0])-(b[0]-a[0])*(c[2]-a[2]),(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0])); assert.ok(area2 > tol * tol); } } +function pickEdge(mesh) { const topo = extractTopology(mesh); const edge = topo.edges.find(e => e.a[0] === -10 && e.b[0] === 10 && e.a[1] === -10 && e.b[1] === -10); assert.ok(edge, '应找到立方体底前棱'); return { topo, edge, ref: makeStableEdgeRef(edge, topo) }; } + +let passed = 0; +function test(name, fn) { try { fn(); passed++; console.log(`✓ ${name}`); } catch (err) { console.error(`✗ ${name}: ${err.message}`); throw err; } } + +test('立方体单边 5mm 倒角生成真实封闭网格', () => { + const m = cube(), { ref } = pickEdge(m); + const out = buildChamferMesh({ mesh: m, edgeRefs: [ref], distance: 5 }); + assert.ok(out.tris.length > m.tris.length, '倒角应增加真实几何面'); + assert.equal(out.selectedEdges.length, 1); closed(out); hasNoDegenerate(out); + const solid = manifoldFromMeshData(out, '倒角测试网格'); + assert.equal(solid.status(), 'NoError', '内核应接受焊接后的封闭流形'); + assert.ok(solid.volume() > 0 && solid.volume() < 8000, '倒角后体积应为合理正值'); + solid.delete(); +}); +test('稳定边引用在等价重建中唯一解析', () => { const m = cube(), { topo, ref } = pickEdge(m); assert.equal(resolveEdgeRef(JSON.parse(JSON.stringify(ref)), topo).key.length > 0, true); }); +test('距离过大、引用失效和多边选择给出中文诊断', () => { + const m = cube(), { ref } = pickEdge(m); + assert.throws(() => buildChamferMesh({ mesh:m, edgeRefs:[ref], distance:20 }), /倒角距离过大/); + const bad = { ...ref, signature: 'missing' }; assert.throws(() => buildChamferMesh({ mesh:m, edgeRefs:[bad], distance:2 }), /引用已失效|目标边不存在/); + assert.throws(() => buildChamferMesh({ mesh:m, edgeRefs:[ref, ref], distance:2 }), /一条凸直棱/); +}); +test('非封闭与凹/非凸输入被拒绝', () => { + const m = cube(); const open = { verts:m.verts, tris:m.tris.slice(0, -3) }; assert.throws(() => extractTopology(open), /封闭二流形/); + const concave = cube(); concave.verts[18] = 0; concave.verts[19] = 0; concave.verts[20] = 0; + const concaveEdge = extractTopology(concave).edges.find(e => e.a[0] === -10 && e.b[0] === 10 && e.a[1] === -10 && e.b[1] === -10); + assert.throws(() => buildChamferMesh({ mesh:concave, edgeRefs:[concaveEdge], distance:2 }), /凸实体|非凸/); +}); +test('连续 100 次重建稳定且小于一秒', () => { + const m=cube(), { ref }=pickEdge(m), start=performance.now(); let count=0; + for(let i=0;i<100;i++) { const out=buildChamferMesh({mesh:m,edgeRefs:[ref],distance:5}); count += out.tris.length; } + assert.ok(count > 0); assert.ok(performance.now()-start < 1000, '100 次小立方体倒角应小于 1 秒'); +}); +console.log(`倒角独立测试通过 ${passed}/5`); diff --git a/tests/test.drawing.mjs b/tests/test.drawing.mjs new file mode 100644 index 0000000..edd090c --- /dev/null +++ b/tests/test.drawing.mjs @@ -0,0 +1,13 @@ +import assert from 'node:assert/strict'; +import { createDrawing, layoutThreeViews, addView, addDimension, addSectionView, renderDrawingSVG, drawingFromJSON } from '../js/features/drawing.js'; + +const mesh = { verts: new Float32Array([-10,-5,0,10,-5,0,10,5,0,-10,5,0,-10,-5,20,10,-5,20,10,5,20,-10,5,20]), tris: new Uint32Array([0,1,2,0,2,3,4,6,5,4,7,6,0,4,5,0,5,1,1,5,6,1,6,2,2,6,7,2,7,3,4,0,3,4,3,7]) }; +const d = createDrawing({ name: '测试零件' }); +for (const v of layoutThreeViews(mesh, { scale: 2 })) addView(d, v); +addDimension(d, { value: 20, text: '20 ±0.1', a: [20, 60], b: [60, 60], tolerance: '' }); +addSectionView(d, { mesh }); +assert.ok(d.views.at(-1).projection?.segments?.length > 0, '剖视图应包含剖面轮廓'); +const svg = renderDrawingSVG(d); +assert.ok(svg.startsWith(' e.a[0] === -10 && e.b[0] === 10 && e.a[1] === -10 && e.b[1] === -10); + assert.ok(edge, '应找到立方体底前棱'); + return { topo, edge, ref: makeStableEdgeRef(edge, topo) }; +} +function noDegenerate(mesh) { + for (let i = 0; i < mesh.tris.length; i += 3) { + const p = j => { const k = mesh.tris[i + j] * 3; return [mesh.verts[k], mesh.verts[k + 1], mesh.verts[k + 2]]; }; + const a=p(0), b=p(1), c=p(2), u=[b[0]-a[0],b[1]-a[1],b[2]-a[2]], v=[c[0]-a[0],c[1]-a[1],c[2]-a[2]]; + assert.ok(Math.hypot(u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]) > 1e-7); + } +} +function faceNormalsAlongFillet(mesh) { + const seen = new Set(); + for (let i = 0; i < mesh.tris.length; i += 3) { + const ids=[mesh.tris[i],mesh.tris[i+1],mesh.tris[i+2]], p=ids.map(id=>[mesh.verts[id*3],mesh.verts[id*3+1],mesh.verts[id*3+2]]); + const u=[p[1][0]-p[0][0],p[1][1]-p[0][1],p[1][2]-p[0][2]],v=[p[2][0]-p[0][0],p[2][1]-p[0][1],p[2][2]-p[0][2]]; + const n=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]], l=Math.hypot(...n); + if (l && Math.abs(n[1]) > 0.02 && Math.abs(n[2]) > 0.02 && Math.abs(n[0]) < 1e-4) seen.add(`${Math.round(n[1]/l*1000)},${Math.round(n[2]/l*1000)}`); + } + return seen.size; +} +let passed=0; +function test(name, fn) { try { fn(); passed++; console.log(`✓ ${name}`); } catch (e) { console.error(`✗ ${name}: ${e.message}`); throw e; } } + +test('单凸直棱生成闭合、正体积的多段真实圆角', () => { + const mesh=cube(), {ref}=pick(mesh), out=buildFilletMesh({mesh,edgeRefs:[ref],radius:4,segments:8}); + assert.ok(out.tris.length > mesh.tris.length); assert.equal(out.segments, 8); extractTopology(out); noDegenerate(out); + assert.ok(faceNormalsAlongFillet(out) >= 8, '圆角带应至少包含每段不同的过渡法向'); + const solid=manifoldFromMeshData(out, '圆角测试网格'); + assert.equal(solid.status(), 'NoError'); assert.ok(solid.volume()>0 && solid.volume()<8000); solid.delete(); +}); +test('稳定边引用可序列化后唯一解析', () => { const mesh=cube(), {topo,ref}=pick(mesh); assert.equal(resolveEdgeRef(JSON.parse(JSON.stringify(ref)),topo).key.length>0,true); }); +test('过大半径、失效引用和多边选择被拒绝', () => { + const mesh=cube(), {ref}=pick(mesh); + assert.throws(()=>buildFilletMesh({mesh,edgeRefs:[ref],radius:20}),/圆角半径过大/); + assert.throws(()=>buildFilletMesh({mesh,edgeRefs:[{...ref,signature:'missing'}],radius:2}),/引用已失效|目标边不存在/); + assert.throws(()=>buildFilletMesh({mesh,edgeRefs:[ref,ref],radius:2}),/一条凸直棱/); +}); +test('凹体和退化分段被拒绝', () => { + const concave=cube(); concave.verts[18]=0; concave.verts[19]=0; concave.verts[20]=0; + const edge=extractTopology(concave).edges.find(e=>e.a[0]===-10&&e.b[0]===10&&e.a[1]===-10&&e.b[1]===-10); + assert.throws(()=>buildFilletMesh({mesh:concave,edgeRefs:[edge],radius:2}),/凸实体|凹边|非凸/); + const mesh=cube(), {ref}=pick(mesh); assert.throws(()=>buildFilletMesh({mesh,edgeRefs:[ref],radius:2,segments:1}),/分段数/); +}); +test('重复构建性能稳定', () => { + const mesh=cube(), {ref}=pick(mesh), start=performance.now(); let faces=0; + for(let i=0;i<20;i++) faces+=buildFilletMesh({mesh,edgeRefs:[ref],radius:4,segments:8}).tris.length; + assert.ok(faces>0); assert.ok(performance.now()-start<1000, '20 次小立方体圆角应小于 1 秒'); +}); +console.log(`圆角独立测试通过 ${passed}/5`); diff --git a/tests/test.loft.mjs b/tests/test.loft.mjs new file mode 100644 index 0000000..d84b9a8 --- /dev/null +++ b/tests/test.loft.mjs @@ -0,0 +1,47 @@ +import { alignProfileStart, buildLoftMesh, resampleClosedProfile } from '../js/features/loft.js'; + +let pass = 0, fail = 0; +const ok = (v, msg) => { if (v) { pass++; console.log(' OK ' + msg); } else { fail++; console.error(' FAIL ' + msg); } }; +const mustThrow = (fn, text) => { try { fn(); ok(false, text); } catch (e) { ok(e.message.includes('放样'), text + ':' + e.message); } }; +const square = (z, size = 10) => [[-size, -size, z], [size, -size, z], [size, size, z], [-size, size, z]]; +const circle = (z, r, n = 16) => Array.from({ length: n }, (_, i) => { const a = 2 * Math.PI * i / n; return [r * Math.cos(a), r * Math.sin(a), z]; }); +const signedVolume = m => { let v = 0; for (let i = 0; i < m.tris.length; i += 3) { const ids = [m.tris[i], m.tris[i + 1], m.tris[i + 2]]; const a = ids.map(q => m.verts[q * 3]); const b = ids.map(q => m.verts[q * 3 + 1]); const c = ids.map(q => m.verts[q * 3 + 2]); v += a[0] * (b[1] * c[2] - b[2] * c[1]) + a[1] * (b[2] * c[0] - b[0] * c[2]) + a[2] * (b[0] * c[1] - b[1] * c[0]); } return v / 6; }; +const closed = m => { const edges = new Map(); for (let i = 0; i < m.tris.length; i += 3) for (const [a, b] of [[m.tris[i], m.tris[i + 1]], [m.tris[i + 1], m.tris[i + 2]], [m.tris[i + 2], m.tris[i]]]) { const k = a < b ? a + ':' + b : b + ':' + a; edges.set(k, (edges.get(k) || 0) + 1); } return [...edges.values()].every(n => n === 2); }; + +console.log('== M03:等顶点双截面 =='); +const m03 = buildLoftMesh({ profiles: [square(0), square(20, 6)] }); +ok(m03.verts.length === 24 && m03.tris.length === 36, '矩形到矩形生成侧壁与两端封闭'); +ok(closed(m03) && signedVolume(m03) > 0, '输出闭合、朝外且体积为正'); +const reversed = buildLoftMesh({ profiles: [square(0), square(20).reverse()] }); +ok(closed(reversed) && signedVolume(reversed) > 0, '反向输入自动统一环方向'); +const aligned = alignProfileStart(square(0), [...square(20).slice(2), ...square(20).slice(0, 2)]); +ok(aligned[0][0] === -10 && aligned[0][1] === -10, '自动选择截面起点对齐'); + +console.log('== M04:多截面与异顶点数 =='); +const t0 = performance.now(); +const m04 = buildLoftMesh({ profiles: [square(0), circle(10, 8, 12), square(20, 5)], resampleCount: 24 }); +const elapsed = performance.now() - t0; +ok(m04.verts.length === 3 * 24 * 3 && closed(m04), '方—圆—方多截面弧长重采样放样'); +ok(signedVolume(m04) > 0, '多截面放样体积为正'); +ok(elapsed < 1000, `典型三截面构建 ${elapsed.toFixed(2)}ms(预算 <1s)`); +const json = JSON.parse(JSON.stringify({ profiles: [square(0), circle(20, 8, 8)], resampleCount: 16, capStart: true, capEnd: true })); +ok(closed(buildLoftMesh(json)), '截面引用参数可 JSON 序列化后重建'); +const sample = resampleClosedProfile(square(0), 12); +ok(sample.length === 12 && sample.every(p => p.length === 3), '闭合轮廓均匀弧长重采样'); +const planeProfile = { points: [[-5, -5], [5, -5], [5, 5], [-5, 5]], plane: { origin: [0, 0, 30], xAxis: [1, 0, 0], yAxis: [0, 1, 0] } }; +ok(closed(buildLoftMesh({ profiles: [square(20, 5), planeProfile] })), 'ProfileData 二维点和坐标平面可直接输入'); +let stable = true, expectedTriCount = 0; +for (let i = 0; i < 10; i++) { const mesh = buildLoftMesh({ profiles: [square(0), circle(10, 8, 12), square(20, 5)], resampleCount: 24 }); if (i && mesh.tris.length !== expectedTriCount) stable = false; expectedTriCount = mesh.tris.length; } +ok(stable, '连续 10 次重建网格规模稳定'); + +console.log('== 诊断 =='); +mustThrow(() => buildLoftMesh({ profiles: [square(0), square(0)] }), '共面重复截面被拒绝'); +mustThrow(() => buildLoftMesh({ profiles: [square(0), [[-10, -10, 10], [10, 10, 10], [-10, 10, 10], [10, -10, 10]]] }), '自交截面被拒绝'); +mustThrow(() => buildLoftMesh({ profiles: [square(0), [[0, 0, 10], [1, 0, 10], [2, 0, 10]]] }), '退化截面被拒绝'); +mustThrow(() => buildLoftMesh({ profiles: [square(0), [[-10, -10, 10], [10, -10, 10], [10, 10, 13], [-10, 10, 10]]] }), '非共面截面被拒绝'); +mustThrow(() => buildLoftMesh({ profiles: [square(0), [[-10, -10, 10], [10, -10, 10], [10, 10, 20], [-10, 10, 20]]] }), '倾斜截面被拒绝'); +mustThrow(() => buildLoftMesh({ profiles: [square(0), square(20), square(10)] }), '截面顺序翻转被拒绝'); +mustThrow(() => buildLoftMesh({ profiles: [square(0), square(20)], resampleCount: 2 }), '非法重采样数被拒绝'); + +console.log(`\\n结果:${pass} 通过 / ${fail} 失败`); +process.exit(fail ? 1 : 0); diff --git a/tests/test.mjs b/tests/test.mjs index 6235206..3017b71 100644 --- a/tests/test.mjs +++ b/tests/test.mjs @@ -1,6 +1,6 @@ // MiniSW 核心链路 Node 冒烟测试(真实调用 MiniSW 源码模块) -const base = 'file:///c:/Users/32919/.trae-cn/work/6a78857c0515f1e48ec3076f/swjs/'; -const { initGeometry, buildExtrude, buildRevolve, booleanOp, meshFromManifold, toBinarySTL, getMod } = await import(base + 'geometry.js'); +const base = new URL('../js/', import.meta.url).href; +const { initGeometry, buildExtrude, buildRevolve, buildSweep, booleanOp, meshFromManifold, toBinarySTL, getMod } = await import(base + 'geometry.js'); const { Doc } = await import(base + 'doc.js'); const { parseNaturalLanguage, validateOps, executeOps } = await import(base + 'agent.js'); const { findLoops } = await import(base + 'sketch.js'); @@ -220,5 +220,230 @@ d17.addFeature({ type: 'revolve', sketchId: sk17.id, params: { axisEntityId: 999 d17.rebuild(); ok(!!d17.features[0].error && d17.features[0].error.includes('轴'), `轴不存在被拦截(${d17.features[0].error})`); +console.log('== 18. 圆形截面扫描:直线 + 四分之一圆弧 =='); +const straightSweep = buildSweep({ path: [[0, 0, 0], [80, 0, 0]], radius: 5, segments: 32 }); +const straightBB = straightSweep.boundingBox(); +ok(straightSweep.status() === 'NoError', '直线扫描生成有效实体'); +ok(Math.abs(straightBB.max[0] - straightBB.min[0] - 80) < 0.1 && Math.abs(straightBB.max[1] - straightBB.min[1] - 10) < 0.2, + '直线扫描包围盒 80×10'); +const arcPath = []; +for (let i = 0; i <= 16; i++) { + const a = Math.PI * 0.5 * i / 16; + arcPath.push([50 * Math.cos(a), 50 * Math.sin(a), 0]); +} +const arcSweep = buildSweep({ path: arcPath, radius: 4, segments: 32 }); +ok(arcSweep.status() === 'NoError' && arcSweep.volume() > 0, '四分之一圆弧扫描生成有效实体'); +straightSweep.delete(); +arcSweep.delete(); + +const sweepOps = parseNaturalLanguage('沿半径50的四分之一圆弧扫描直径8的圆形截面'); +ok(sweepOps.length === 1 && sweepOps[0].op === 'sweep' && Math.abs(sweepOps[0].radius - 4) < 1e-6, 'AI 识别圆弧扫描与截面直径'); +ok(!validateOps(sweepOps), '扫描 op 校验通过'); +const d18 = new Doc(); +executeOps(d18, sweepOps); +d18.rebuild(); +ok(!d18.features.some(f => f.error) && !!d18.bodyMesh, 'AI 扫描端到端重建成功'); +const d18Reloaded = Doc.fromJSON(d18.toJSON()); +d18Reloaded.rebuild(); +ok(!d18Reloaded.features.some(f => f.error) && d18Reloaded.features[0].params.path.length === 17, '扫描特征保存/打开后可重建'); +ok(!!validateOps([{ op: 'sweep', path: [[0, 0, 0]], radius: 4 }]), '退化扫描路径在执行前被拦截'); + +console.log('== 19. 第一阶段批次 A 集成:草图扫描 / 放样 / 参考几何 =='); +const d19ref = new Doc(); +const offsetPlane = d19ref.addReference({ + type: 'plane', name: '偏移面10', sourceRefs: ['front'], params: { kind: 'offset', distance: 10 }, +}); +const sk19ref = d19ref.addSketch({ planeRefId: offsetPlane.id, name: '偏移面草图' }); +d19ref.addEntity(sk19ref.id, { type: 'rect', c: [0, 0], w: 20, h: 10 }); +d19ref.addFeature({ type: 'extrude', sketchId: sk19ref.id, params: { depth: 5 } }); +d19ref.rebuild(); +ok(!d19ref.features.some(f => f.error), '偏移基准面上的草图可拉伸'); +ok(Math.abs(d19ref.bbox.min[2] - 10) < 1e-4 && Math.abs(d19ref.bbox.max[2] - 15) < 1e-4, '偏移面拉伸位于 z=10..15'); +const d19refReloaded = Doc.fromJSON(d19ref.toJSON()); +d19refReloaded.rebuild(); +ok(d19refReloaded.references.length === 1 && !d19refReloaded.features.some(f => f.error), '参考几何保存/打开后可重建'); +const d19angled = new Doc(); +const axis19 = d19angled.addReference({ type: 'axis', name: 'Y轴', origin: [0, 0, 0], direction: [0, 1, 0], sourceRefs: [], params: {} }); +const angledPlane19 = d19angled.addReference({ type: 'plane', name: '旋转面90', sourceRefs: ['front', axis19.id], params: { kind: 'angled', degrees: 90 } }); +const angledSketch19 = d19angled.addSketch({ planeRefId: angledPlane19.id }); +d19angled.addEntity(angledSketch19.id, { type: 'rect', c: [0, 0], w: 20, h: 10 }); +d19angled.addFeature({ type: 'extrude', sketchId: angledSketch19.id, params: { depth: 5 } }); +d19angled.rebuild(); +ok(!d19angled.features.some(f => f.error), '角度基准面上的草图可拉伸'); +const angledSize19 = [0, 1, 2].map(i => d19angled.bbox.max[i] - d19angled.bbox.min[i]); +ok(Math.abs(angledSize19[0] - 5) < 0.1 && Math.abs(angledSize19[1] - 10) < 0.1 && Math.abs(angledSize19[2] - 20) < 0.1, '角度面拉伸方向与局部坐标正确'); + +const d19sweep = new Doc(); +const path19 = d19sweep.addSketch({ plane: 'front', name: '扫描路径' }); +d19sweep.addEntity(path19.id, { type: 'line', p1: [0, 0], p2: [60, 0] }); +const profile19 = d19sweep.addSketch({ plane: 'right', name: '扫描截面' }); +d19sweep.addEntity(profile19.id, { type: 'rect', c: [0, 0], w: 8, h: 6 }); +d19sweep.addFeature({ type: 'sweep', params: { pathSketchId: path19.id, profileSketchId: profile19.id, frameMode: 'parallelTransport', twistDegrees: 0 } }); +d19sweep.rebuild(); +ok(!d19sweep.features.some(f => f.error) && !!d19sweep.bodyMesh, '路径草图 + 矩形截面草图扫描成功'); +ok(Math.abs(d19sweep.bbox.max[0] - d19sweep.bbox.min[0] - 60) < 0.1, '草图扫描路径长度为 60mm'); + +const d19loft = new Doc(); +const lp1 = d19loft.addSketch({ plane: 'front', offset: 0, name: '放样截面1' }); +d19loft.addEntity(lp1.id, { type: 'rect', c: [0, 0], w: 30, h: 20 }); +const lp2 = d19loft.addSketch({ plane: 'front', offset: 30, name: '放样截面2' }); +d19loft.addEntity(lp2.id, { type: 'circle', c: [0, 0], r: 6 }); +d19loft.addFeature({ type: 'loft', params: { profileSketchIds: [lp1.id, lp2.id], resampleCount: 32 } }); +d19loft.rebuild(); +ok(!d19loft.features.some(f => f.error) && !!d19loft.bodyMesh, '矩形草图到圆形草图放样成功'); +ok(Math.abs(d19loft.bbox.max[2] - d19loft.bbox.min[2] - 30) < 0.1, '放样跨越两个相距 30mm 的截面'); + +const loftOps19 = parseNaturalLanguage('从40×30的矩形放样到直径20的圆形,高度50'); +ok(loftOps19.length === 1 && loftOps19[0].op === 'loft' && !validateOps(loftOps19), 'AI 识别矩形到圆形放样'); +const d19aiLoft = new Doc(); +executeOps(d19aiLoft, loftOps19); +d19aiLoft.rebuild(); +ok(!d19aiLoft.features.some(f => f.error) && Math.abs(d19aiLoft.bbox.max[2] - 50) < 0.1, 'AI 放样端到端重建成功'); +const refOps19 = parseNaturalLanguage('创建距离前视面10mm的偏移基准面'); +ok(refOps19.length === 1 && refOps19[0].op === 'reference_plane' && !validateOps(refOps19), 'AI 识别偏移基准面'); +const d19aiRef = new Doc(); +const createdRef19 = executeOps(d19aiRef, refOps19); +ok(createdRef19.references.length === 1 && d19aiRef.resolveReference(createdRef19.references[0]).origin[2] === 10, 'AI 创建的参考基准面可解析'); + +console.log('== 20. 第一阶段批次 B 集成:筋 / 拉伸终止条件 =='); +const d20term = new Doc(); +const base20 = d20term.addSketch({ plane: 'front' }); +d20term.addEntity(base20.id, { type: 'rect', c: [0, 0], w: 80, h: 50 }); +d20term.addFeature({ type: 'extrude', sketchId: base20.id, params: { depth: 12 } }); +const cut20 = d20term.addSketch({ plane: 'front' }); +d20term.addEntity(cut20.id, { type: 'circle', c: [0, 0], r: 5 }); +d20term.addFeature({ type: 'cut', sketchId: cut20.id, params: { mode: 'toNext' } }); +d20term.rebuild(); +ok(!d20term.features.some(f => f.error), '到下一面切除在当前主体上精确求深度'); +const expected20 = 80 * 50 * 12 - Math.PI * 25 * 12; +ok(Math.abs(d20term.bodyManifold.volume() - expected20) / expected20 < 0.01, '到下一面切除贯穿首个相交区间'); + +const d20face = new Doc(); +const face20 = d20face.addReference({ type: 'plane', name: '终止面25', sourceRefs: ['front'], params: { kind: 'offset', distance: 25 } }); +const faceSketch20 = d20face.addSketch({ plane: 'front' }); +d20face.addEntity(faceSketch20.id, { type: 'rect', c: [0, 0], w: 30, h: 20 }); +d20face.addFeature({ type: 'extrude', sketchId: faceSketch20.id, params: { mode: 'toFace', targetFaceRef: face20.id } }); +d20face.rebuild(); +ok(!d20face.features.some(f => f.error) && Math.abs(d20face.bbox.max[2] - 25) < 1e-4, '到指定参考面拉伸精确终止于 z=25'); +const faceReload20 = Doc.fromJSON(d20face.toJSON()); +faceReload20.rebuild(); +ok(!faceReload20.features.some(f => f.error) && faceReload20.features[0].params.targetFaceRef === face20.id, '到面稳定引用保存/打开后可重建'); + +const d20body = new Doc(); +const bodyBase20 = d20body.addSketch({ plane: 'front' }); +d20body.addEntity(bodyBase20.id, { type: 'rect', c: [0, 0], w: 60, h: 40 }); +d20body.addFeature({ type: 'extrude', sketchId: bodyBase20.id, params: { depth: 10 } }); +const bodyCut20 = d20body.addSketch({ plane: 'front' }); +d20body.addEntity(bodyCut20.id, { type: 'circle', c: [10, 0], r: 3 }); +d20body.addFeature({ type: 'cut', sketchId: bodyCut20.id, params: { mode: 'toBody', targetBodyRef: 'current' } }); +d20body.rebuild(); +ok(!d20body.features.some(f => f.error), '单实体文档的到实体终止使用 current 稳定目标'); + +const d20rib = new Doc(); +const ribBase20 = d20rib.addSketch({ plane: 'front' }); +d20rib.addEntity(ribBase20.id, { type: 'rect', c: [0, 0], w: 90, h: 50 }); +d20rib.addFeature({ type: 'extrude', sketchId: ribBase20.id, params: { depth: 10 } }); +const ribWall20 = d20rib.addSketch({ plane: 'right', name: '侧板草图' }); +d20rib.addEntity(ribWall20.id, { type: 'rect', c: [20, 0], w: 40, h: 24 }); +d20rib.addFeature({ type: 'extrude', sketchId: ribWall20.id, params: { depth: 4, symmetric: true } }); +const ribPath20 = d20rib.addSketch({ plane: 'front', offset: 5, name: '筋路径' }); +d20rib.addEntity(ribPath20.id, { type: 'line', p1: [-30, 0], p2: [30, 0] }); +d20rib.addFeature({ type: 'rib', sketchId: ribPath20.id, params: { thickness: 3, extent: 25, side: 'both' } }); +d20rib.rebuild(); +ok(!d20rib.features.some(f => f.error) && Math.abs(d20rib.bbox.max[2] - 40) < 0.1, '底板、侧板与开放路径筋合并为单一主体'); +const ribReload20 = Doc.fromJSON(d20rib.toJSON()); +ribReload20.rebuild(); +ok(!ribReload20.features.some(f => f.error) && ribReload20.features.at(-1).params.thickness === 3, '筋特征保存/打开后可重建'); + +const ribOps20 = parseNaturalLanguage('做一个120×80×12的底板,加一条加强筋,厚度8mm,筋高50mm'); +const ribOp20 = ribOps20.find(o => o.op === 'rib'); +ok(ribOp20 && ribOp20.thickness === 8 && ribOp20.extent === 50 && !validateOps(ribOps20), 'AI 精确识别加强筋厚度 8mm 和筋高 50mm'); +const ribAi20 = new Doc(); +executeOps(ribAi20, ribOps20); +ribAi20.rebuild(); +ok(!ribAi20.features.some(f => f.error) && ribAi20.features.at(-1).type === 'rib' && Math.abs(ribAi20.bbox.max[2] - 56) < 0.1, 'AI 加强筋端到端重建成功且整体高度为 56mm'); + +console.log('== 21. 第一阶段批次 B 集成:实体倒角 / 实体圆角 =='); +function edgeBoxDoc(type) { + const d = new Doc(); + const s = d.addSketch({ plane: 'front' }); + d.addEntity(s.id, { type: 'rect', c: [0, 0], w: 40, h: 30 }); + d.addFeature({ type: 'extrude', sketchId: s.id, params: { depth: 20 } }); + d.addFeature({ + type, + params: { + edgeRefs: [{ type: 'edge-query', point: [0, -15, 0], direction: [1, 0, 0] }], + distance: 5, radius: 5, segments: 8, + }, + }); + d.rebuild(); + return d; +} +const chamfer21 = edgeBoxDoc('chamfer'); +ok(!chamfer21.features.some(f => f.error) && chamfer21.bodyManifold.status() === 'NoError', '立方体单条凸直棱实体倒角通过内核验证'); +ok(Math.abs(chamfer21.bodyManifold.volume() - 23500) < 1, `实体倒角移除真实三角棱柱体积(实测 ${chamfer21.bodyManifold.volume().toFixed(2)})`); +const chamferReload21 = Doc.fromJSON(chamfer21.toJSON()); +chamferReload21.rebuild(); +ok(!chamferReload21.features.some(f => f.error), '实体倒角稳定边查询保存/打开后可重建'); + +const fillet21 = edgeBoxDoc('fillet'); +const filletVolume21 = fillet21.bodyManifold?.volume() || 0; +ok(!fillet21.features.some(f => f.error) && fillet21.bodyManifold.status() === 'NoError', '立方体单条凸直棱恒定半径圆角通过内核验证'); +ok(filletVolume21 > 23500 && filletVolume21 < 24000, `实体圆角体积位于倒角与原方块之间(实测 ${filletVolume21.toFixed(2)})`); +const filletReload21 = Doc.fromJSON(fillet21.toJSON()); +filletReload21.rebuild(); +ok(!filletReload21.features.some(f => f.error) && filletReload21.features.at(-1).params.segments === 8, '实体圆角保存/打开后可重建'); + +const chamferOps21 = parseNaturalLanguage('做一个40×30×20的方块,给一条实体棱边倒角5mm'); +ok(chamferOps21.some(o => o.op === 'chamfer_edge') && !validateOps(chamferOps21), 'AI 识别实体棱边倒角'); +const chamferAi21 = new Doc(); executeOps(chamferAi21, chamferOps21); chamferAi21.rebuild(); +ok(!chamferAi21.features.some(f => f.error) && chamferAi21.features.at(-1).type === 'chamfer', 'AI 实体倒角端到端重建成功'); +const filletOps21 = parseNaturalLanguage('做一个40×30×20的方块,给一条实体棱边做R5圆角'); +ok(filletOps21.some(o => o.op === 'fillet_edge') && !validateOps(filletOps21), 'AI 识别实体棱边 R5 圆角'); +const filletAi21 = new Doc(); executeOps(filletAi21, filletOps21); filletAi21.rebuild(); +ok(!filletAi21.features.some(f => f.error) && filletAi21.features.at(-1).type === 'fillet', 'AI 实体圆角端到端重建成功'); + +console.log('== 22. M10 第一阶段综合验收:AI 一句话轴承座 =='); +const bearingText22 = '生成一个120×80×12的轴承座,主体凸台直径60高32,中心通孔直径30,两条厚4mm加强筋,实体倒角和R4实体圆角'; +const bearingOps22 = parseNaturalLanguage(bearingText22); +ok(Array.isArray(bearingOps22) && !validateOps(bearingOps22), '轴承座中文验收句生成合法操作序列'); +ok(bearingOps22.filter(o => o.op === 'rib').length >= 2 && bearingOps22.some(o => o.op === 'chamfer_edge') && bearingOps22.some(o => o.op === 'fillet_edge'), '操作序列包含双筋、实体倒角和实体圆角'); +ok(bearingOps22.some(o => o.op === 'reference_plane') && bearingOps22.some(o => o.op === 'cut' && o.mode === 'toNext'), '操作序列包含参考基准面和非固定深度终止条件'); +const bearing22 = new Doc(); +executeOps(bearing22, bearingOps22); +const bearingStart22 = performance.now(); +bearing22.rebuild(); +const bearingElapsed22 = performance.now() - bearingStart22; +ok(!bearing22.features.some(f => f.error) && bearing22.bodyManifold?.status() === 'NoError', 'AI 轴承座全部特征按顺序重建且实体有效'); +ok(bearing22.features.filter(f => f.type === 'rib').length === 2 && bearing22.references.length === 1, '轴承座特征树含两条筋和一个参考面'); +ok(bearingElapsed22 < 1000, `完整轴承座单次重建 ${bearingElapsed22.toFixed(1)}ms(预算 <1000ms)`); +const bearingVolume22 = bearing22.bodyManifold.volume(); +const bearingSTL22 = toBinarySTL(bearing22.bodyMesh); +ok(bearingSTL22.length > 84 && new DataView(bearingSTL22.buffer).getUint32(80, true) === bearing22.bodyMesh.tris.length / 3, '轴承座 STL 导出三角数与网格一致'); + +const bearingReload22 = Doc.fromJSON(bearing22.toJSON()); +bearingReload22.rebuild(); +ok(!bearingReload22.features.some(f => f.error) && Math.abs(bearingReload22.bodyManifold.volume() - bearingVolume22) < 1e-3, '轴承座保存/重新打开后体积一致'); +const ribFeature22 = bearingReload22.features.find(f => f.type === 'rib'); +bearingReload22.updateFeature(ribFeature22.id, { thickness: 5 }); +bearingReload22.rebuild(); +ok(!bearingReload22.features.some(f => f.error) && bearingReload22.featureById(ribFeature22.id).params.thickness === 5, '第一阶段新特征参数可编辑并全树重建'); + +let stableBearing22 = true, lastBearing22 = 0; +const repeatStart22 = performance.now(); +for (let i = 0; i < 10; i++) { + bearing22.rebuild(); + const volume = bearing22.bodyManifold.volume(); + if (i && Math.abs(volume - lastBearing22) > 1e-3) stableBearing22 = false; + lastBearing22 = volume; +} +ok(stableBearing22 && performance.now() - repeatStart22 < 3000, '轴承座连续 10 次重建稳定且无可见 WASM 生命周期退化'); +const legacy22 = bearing22.toJSON(); +legacy22.version = 1; delete legacy22.references; +legacy22.features = legacy22.features.filter(f => !['chamfer', 'fillet', 'rib'].includes(f.type)); +legacy22.sketches = legacy22.sketches.filter(s => !s.planeRefId); +const legacyDoc22 = Doc.fromJSON(legacy22); legacyDoc22.rebuild(); +ok(Array.isArray(legacyDoc22.references) && !legacyDoc22.features.some(f => f.error), '旧版无 references 字段的工程仍可打开重建'); + console.log(`\n结果:${pass} 通过 / ${fail} 失败`); process.exit(fail ? 1 : 0); diff --git a/tests/test.phase2.mjs b/tests/test.phase2.mjs new file mode 100644 index 0000000..fbdcb57 --- /dev/null +++ b/tests/test.phase2.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { initGeometry } from '../js/geometry.js'; +import { Doc } from '../js/doc.js'; +import { parseNaturalLanguage, validateOps, executeOps } from '../js/agent.js'; + +const ok = (value, message) => assert.ok(value, message); +await initGeometry(); + +const d = new Doc(); +const ops = [ + { op: 'sketch', plane: 'front', name: '参数草图' }, + { op: 'rect', cx: 0, cy: 0, w: 'W', h: 40 }, + { op: 'set_dimension', name: 'W', expression: '120' }, + { op: 'extrude', depth: 12, name: '参数主体' }, +]; +ok(!validateOps(ops), '命名尺寸序列校验通过'); +executeOps(d, ops); +d.rebuild(); +ok(Math.abs(d.bbox.max[0] - d.bbox.min[0] - 120) < 1e-6, '命名尺寸驱动矩形宽度'); +d.setDimension('W', '150'); +d.rebuild(); +ok(Math.abs(d.bbox.max[0] - d.bbox.min[0] - 150) < 1e-6, '修改命名尺寸后主体联动'); + +const ellipseDoc = new Doc(); +executeOps(ellipseDoc, parseNaturalLanguage('做一个 80×40 的椭圆,高 10')); +ellipseDoc.rebuild(); +ok(ellipseDoc.bodyMesh && ellipseDoc.bbox.max[2] - ellipseDoc.bbox.min[2] > 9.9, '椭圆实体可重建'); + +const slotDoc = new Doc(); +executeOps(slotDoc, parseNaturalLanguage('做一个 80×20 的长圆槽,高 8')); +slotDoc.rebuild(); +ok(slotDoc.bodyMesh && slotDoc.bbox.max[0] - slotDoc.bbox.min[0] > 75, '长圆槽实体可重建'); + +const arrayDoc = new Doc(); +executeOps(arrayDoc, [ + { op: 'sketch', plane: 'front' }, + { op: 'circle', cx: 30, cy: 0, r: 4 }, + { op: 'sketch_pattern_circular', count: 6, cx: 0, cy: 0 }, +]); +ok(arrayDoc.sketches[0].entities.length === 6, '草图圆周阵列生成六个实体'); + +const round = Doc.fromJSON(JSON.parse(JSON.stringify(d.toJSON()))); +round.rebuild(); +ok(Math.abs(round.bbox.max[0] - round.bbox.min[0] - 150) < 1e-6, '命名尺寸保存/打开后保持联动'); + +console.log('phase2 8/8 passed'); diff --git a/tests/test.phase34.mjs b/tests/test.phase34.mjs new file mode 100644 index 0000000..40f9ccc --- /dev/null +++ b/tests/test.phase34.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { initGeometry } from '../js/geometry.js'; +import { Doc } from '../js/doc.js'; +import { parseNaturalLanguage, executeOps, validateOps } from '../js/agent.js'; + +await initGeometry(); +const d = new Doc(); +executeOps(d, [ + { op: 'sketch', plane: 'front' }, { op: 'rect', cx: 0, cy: 0, w: 80, h: 50 }, { op: 'extrude', depth: 10, name: '底座' }, +]); +d.rebuild(); +const drawingOps = parseNaturalLanguage('给当前零件生成一张 A4 横向三视图工程图'); +assert.equal(validateOps(drawingOps), null); +executeOps(d, drawingOps); +assert.equal(d.drawings.length, 1); +assert.equal(d.drawings[0].views.length, 3); + +executeOps(d, [{ op: 'assembly_create', name: '底座装配' }, { op: 'assembly_add_component', id: 'base', source: 'current', fixed: true }, { op: 'assembly_add_component', id: 'slider', source: 'current' }, { op: 'interference_check' }]); +assert.equal(d.assembly.components.length, 2); +assert.ok(d.assembly.components[1].transform.position[0] > 0); +assert.ok(Array.isArray(d.assembly.lastInterferences)); +const round = Doc.fromJSON(JSON.parse(JSON.stringify(d.toJSON()))); +assert.equal(round.drawings.length, 1); +assert.equal(round.assembly.components.length, 2); + +// 自然语言同时描述“先建零件、再装配”时,内置代理应自动重建前置实体, +// 不再把 assembly_add_component 提前执行到空 bodyMesh 上。 +const natural = new Doc(); +const naturalText = '新建一个装配体。先做一个120×80×12mm的底座,顶面中心加一个直径50高20mm的凸台并开直径24mm中心通孔;检查装配体干涉,生成爆炸视图。最后为底座生成A4横向三视图,标注120,并添加A-A剖视图。'; +const naturalOps = parseNaturalLanguage(naturalText); +assert.equal(validateOps(naturalOps), null); +executeOps(natural, naturalOps); +assert.ok(natural.bodyMesh && natural.bodyMesh.verts.length > 0); +assert.equal(natural.assembly?.components.length, 1); +assert.equal(natural.features.filter(f => !f.error).length, 3); +assert.equal(natural.drawings.length, 1); +assert.ok(Array.isArray(natural.assembly.lastInterferences)); +assert.equal(natural.assembly.explode.active, true); +console.log('phase3/4 16/16 passed'); diff --git a/tests/test.reference.mjs b/tests/test.reference.mjs new file mode 100644 index 0000000..f571a2e --- /dev/null +++ b/tests/test.reference.mjs @@ -0,0 +1,57 @@ +import { + makeOffsetPlane, makeAngledPlane, makePlaneThroughPoint, makeAxisFromPoints, + makeAxisFromPlanes, makeCoordinateSystem, resolveReference, +} from '../js/features/reference.js'; + +let pass = 0, fail = 0; +const ok = (condition, message) => { if (condition) { pass++; console.log(` ✓ ${message}`); } else { fail++; console.error(` ✗ ${message}`); } }; +const throws = (fn, text) => { try { fn(); ok(false, text); } catch (error) { ok(error.message.includes(''), text); } }; +const near = (a, b, tolerance = 1e-8) => Math.abs(a - b) < tolerance; +const vecNear = (a, b) => a.every((v, i) => near(v, b[i])); + +console.log('== M06 参考几何 =='); +const front = { id: 'front', type: 'plane', origin: [0, 0, 0], normal: [0, 0, 1], xAxis: [1, 0, 0], sourceRefs: [], params: {} }; +const vertical = { id: 'vertical', type: 'plane', origin: [0, 0, 0], normal: [1, 0, 0], xAxis: [0, 1, 0], sourceRefs: [], params: {} }; +const offset = makeOffsetPlane(front, 10); +ok(vecNear(offset.origin, [0, 0, 10]) && vecNear(offset.normal, [0, 0, 1]), '前视面偏移 10mm'); +const axisZ = { id: 'zAxis', type: 'axis', origin: [0, 0, 0], direction: [0, 0, 5], sourceRefs: [], params: {} }; +const angled = makeAngledPlane(front, axisZ, 30); +ok(near(angled.normal[0], 0) && near(angled.normal[1], 0) && near(angled.xAxis[0], Math.cos(Math.PI / 6)), '绕基准轴旋转 30°'); +const through = makePlaneThroughPoint(front, { id: 'p10', type: 'point', position: [2, 3, 10] }); +ok(vecNear(through.origin, [2, 3, 10]) && vecNear(through.normal, front.normal), '过点平行基准面'); +const pointAxis = makeAxisFromPoints([1, 2, 3], [1, 2, 8]); +ok(vecNear(pointAxis.direction, [0, 0, 1]), '两点基准轴'); +const intersection = makeAxisFromPlanes(front, vertical); +ok(vecNear(intersection.direction, [0, 1, 0]) || vecNear(intersection.direction, [0, -1, 0]), '两个垂直平面交线基准轴'); +throws(() => makeAxisFromPlanes(front, makeOffsetPlane(front, 5)), '平行平面交线被拒绝'); +throws(() => makeAxisFromPoints([0, 0, 0], [0, 0, 0]), '重合点建轴被拒绝'); +const csys = makeCoordinateSystem([1, 2, 3], [2, 0, 0], [1, 4, 0]); +ok(near(csys.xAxis.reduce((s, v, i) => s + v * csys.yAxis[i], 0), 0) && near(csys.zAxis[2], 1), '坐标系正交且为右手系'); +throws(() => makeCoordinateSystem([0, 0, 0], [1, 0, 0], [2, 0, 0]), '共线坐标系提示被拒绝'); + +const refs = { + front, + zAxis: axisZ, + p10: { id: 'p10', type: 'point', position: [0, 0, 12], sourceRefs: [], params: {} }, + offsetRef: { id: 'offsetRef', type: 'plane', sourceRefs: ['front'], params: { kind: 'offset', distance: 10 } }, + angledRef: { id: 'angledRef', type: 'plane', sourceRefs: ['front', 'zAxis'], params: { kind: 'angled', degrees: 30 } }, + throughRef: { id: 'throughRef', type: 'plane', sourceRefs: ['front', 'p10'], params: { kind: 'throughPoint' } }, + centerEntity: { id: 'centerEntity', bbox: { min: [0, 2, 4], max: [4, 6, 8] } }, + centerRef: { id: 'centerRef', type: 'point', sourceRefs: ['centerEntity'], params: { kind: 'entityCenter' } }, +}; +ok(vecNear(resolveReference('offsetRef', refs).origin, [0, 0, 10]), '稳定 ID 偏移引用可解析'); +ok(near(resolveReference('angledRef', refs).xAxis[1], .5), '稳定 ID 角度引用可解析'); +ok(vecNear(resolveReference('throughRef', refs).origin, [0, 0, 12]), '稳定 ID 过点引用可解析'); +ok(vecNear(resolveReference('centerRef', refs).position, [2, 4, 6]), '引用实体中心点'); +const roundTrip = JSON.parse(JSON.stringify(refs)); +ok(vecNear(resolveReference('offsetRef', roundTrip).origin, [0, 0, 10]), 'JSON 往返后结果一致'); +throws(() => resolveReference({ id: 'missing', type: 'plane', sourceRefs: ['absent'], params: { kind: 'offset', distance: 1 } }, refs), '丢失引用被拒绝'); +const cycle = { a: { id: 'a', type: 'plane', sourceRefs: ['b'], params: { kind: 'offset', distance: 1 } }, b: { id: 'b', type: 'plane', sourceRefs: ['a'], params: { kind: 'offset', distance: 1 } } }; +throws(() => resolveReference('a', cycle), '循环引用被检测'); + +const started = performance.now(); +for (let i = 0; i < 1000; i++) resolveReference('angledRef', refs); +const elapsed = performance.now() - started; +ok(elapsed < 200, `1000 次引用解析 ${elapsed.toFixed(2)}ms`); +console.log(`结果:${pass} 通过 / ${fail} 失败`); +process.exit(fail ? 1 : 0); diff --git a/tests/test.rib.mjs b/tests/test.rib.mjs new file mode 100644 index 0000000..8e788c8 --- /dev/null +++ b/tests/test.rib.mjs @@ -0,0 +1,45 @@ +import { initGeometry, getMod, manifoldFromMeshData, buildCylinder } from '../js/geometry.js'; +import { offsetOpenPath, buildRibProfile, buildRibTool } from '../js/features/rib.js'; + +let pass = 0, fail = 0; +const ok = (v, label) => { if (v) { pass++; console.log(' ✓ ' + label); } else { fail++; console.error(' ✗ ' + label); } }; +const throws = (fn, word) => { try { fn(); return false; } catch (e) { return e.message.includes(word); } }; +const area = p => Math.abs(p.reduce((s, a, i) => { const b = p[(i + 1) % p.length]; return s + a[0] * b[1] - a[1] * b[0]; }, 0) / 2); + +await initGeometry(); +const ctx = { manifoldFromMeshData, mod: getMod() }; +console.log('== M05 筋特征 =='); +const line = [[0, 0], [40, 0]]; +const both = offsetOpenPath(line, 3, 'both'); +ok(both.length === 4 && Math.abs(area(both) - 120) < 1e-6, '双侧对称偏置生成 3mm 薄壁轮廓'); +const single = offsetOpenPath(line, 3, 'left'); +ok(Math.min(...single.map(p => p[1])) === 0 && Math.max(...single.map(p => p[1])) === 3, '单侧偏置保留中心线一侧'); +const folded = buildRibProfile({ path: [[0, 0], [20, 0], [20, 20]], thickness: 3, side: 'both', draftDegrees: 5 }); +ok(folded.closed && folded.points.length === 6, '折线路径生成封闭筋轮廓并保存拔模参数'); +ok(throws(() => buildRibProfile({ path: line, thickness: 3, draftDegrees: 45 }), '拔模角'), '不合理拔模角被拒绝'); +ok(throws(() => offsetOpenPath([[0, 0], [0, 0]], 3), '退化'), '退化路径被拒绝'); +ok(throws(() => buildRibTool(null, { path: line, thickness: 3 }), '注入'), '未注入内核上下文被拒绝'); +ok(throws(() => buildRibTool(ctx, { path: [[0, 0, 0], [10, 0, 0], [0, 0, 0]], thickness: 3 }), '开放轮廓'), '闭合三维路径被拒绝'); +ok(throws(() => offsetOpenPath([[0, 0], [1, 0], [1, .01]], 20), '厚度过大'), '厚度过大的折角被拒绝'); +ok(throws(() => offsetOpenPath([[0, 0], [5, 0], [10, 0]], 3), '退化'), '共线折点导致的退化被拒绝'); + +const base = buildCylinder({ r: 30, h: 4, z: 0 }); +const rib = buildRibTool(ctx, { path: [[-20, 0, 1], [20, 0, 1]], thickness: 3, extent: 20, body: base, side: 'both' }); +ok(rib.volume() > 0 && ctx.mod.Manifold.intersection([base, rib]).volume() > 0, '底板与直筋形成可连通实体'); +const concaveRib = buildRibTool(ctx, { path: [[0, 0, 0], [20, 0, 0], [20, 20, 0]], thickness: 4, extent: 10, side: 'both' }); +ok(concaveRib.status() === 'NoError' && concaveRib.volume() > 0, '凹形折线路径的耳切端盖生成有效 Manifold'); +const standardSketchRib = buildRibTool(ctx, { sketch: { plane: 'front', offset: 7, points: [[0, 0], [10, 0]] }, thickness: 2, extent: 3 }); +ok(Math.abs(standardSketchRib.boundingBox().min[2] - 7) < 1e-5, '内置草图平面 offset 正确映射到世界坐标'); +const customSketchRib = buildRibTool(ctx, { sketch: { plane: { origin: [10, 0, 0], normal: [1, 0, 0], xAxis: [0, 1, 0], yAxis: [0, 0, 1] }, offset: 4, points: [[0, 0], [10, 0]] }, thickness: 2, extent: 3 }); +ok(Math.abs(customSketchRib.boundingBox().min[0] - 14) < 1e-5, '自定义草图平面 offset 正确映射到世界坐标'); +const flip = buildRibTool(ctx, { path: [[-20, 0, 2], [20, 0, 2]], thickness: 3, extent: 8, flip: true }); +ok(flip.boundingBox().min[2] < 2 && flip.boundingBox().max[2] <= 2.001, 'flip 反转筋的拉伸方向'); +ok(throws(() => buildRibTool(ctx, { path: [[100, 0, 2], [120, 0, 2]], thickness: 3, extent: 20, body: base }), '不接触主体'), '不接触主体时给出中文诊断'); +let stable = true, first = 0; const started = performance.now(); +for (let i = 0; i < 10; i++) { const m = buildRibTool(ctx, { path: [[-20, 0, 1], [20, 0, 1]], thickness: 3, extent: 20 }); if (i) stable &&= Math.abs(m.volume() - first) < 1e-5; else first = m.volume(); m.delete(); } +const elapsed = performance.now() - started; +ok(stable, '连续 10 次构建体积稳定'); +ok(elapsed < 200, `典型筋 10 次构建 ${elapsed.toFixed(1)}ms`); +rib.delete(); concaveRib.delete(); standardSketchRib.delete(); customSketchRib.delete(); flip.delete(); base.delete(); +console.log(`结果:${pass} 通过 / ${fail} 失败`); +process.exit(fail ? 1 : 0); diff --git a/tests/test.sweep.mjs b/tests/test.sweep.mjs new file mode 100644 index 0000000..a7d6054 --- /dev/null +++ b/tests/test.sweep.mjs @@ -0,0 +1,42 @@ +import { samplePathSketch, profileFromSketch, buildSweepMesh } from '../js/features/sweep.js'; + +let pass = 0, fail = 0; +const ok = (condition, label) => { if (condition) { pass++; console.log(' ✓ ' + label); } else { fail++; console.log(' ✗ ' + label); } }; +const throws = (fn, text) => { try { fn(); return false; } catch (e) { return e.message.includes(text); } }; +const box = mesh => { const a = [Infinity, Infinity, Infinity], b = [-Infinity, -Infinity, -Infinity]; for (let i = 0; i < mesh.verts.length; i += 3) for (let j = 0; j < 3; j++) { a[j] = Math.min(a[j], mesh.verts[i + j]); b[j] = Math.max(b[j], mesh.verts[i + j]); } return { a, b }; }; +const closed = mesh => { const edges = new Map(); for (let i = 0; i < mesh.tris.length; i += 3) for (const [a, b] of [[mesh.tris[i], mesh.tris[i + 1]], [mesh.tris[i + 1], mesh.tris[i + 2]], [mesh.tris[i + 2], mesh.tris[i]]]) { const key = a < b ? `${a}:${b}` : `${b}:${a}`; edges.set(key, (edges.get(key) || 0) + 1); } return [...edges.values()].every(n => n === 2); }; +const volume = mesh => { let v = 0; for (let i = 0; i < mesh.tris.length; i += 3) { const a = mesh.tris[i] * 3, b = mesh.tris[i + 1] * 3, c = mesh.tris[i + 2] * 3; v += mesh.verts[a] * (mesh.verts[b + 1] * mesh.verts[c + 2] - mesh.verts[b + 2] * mesh.verts[c + 1]) - mesh.verts[a + 1] * (mesh.verts[b] * mesh.verts[c + 2] - mesh.verts[b + 2] * mesh.verts[c]) + mesh.verts[a + 2] * (mesh.verts[b] * mesh.verts[c + 1] - mesh.verts[b + 1] * mesh.verts[c]); } return Math.abs(v / 6); }; +const circle = (r, n = 24) => ({ points: Array.from({ length: n }, (_, i) => [r * Math.cos(i * Math.PI * 2 / n), r * Math.sin(i * Math.PI * 2 / n)]), closed: true }); + +console.log('== M02 通用扫描 =='); +const line = { points: [[0, 0, 0], [80, 0, 0]], closed: false }; +const round = buildSweepMesh({ path: line, profile: circle(5) }); +const bb = box(round); ok(round.tris.length > 0 && Math.abs(bb.b[0] - bb.a[0] - 80) < .01, '圆截面沿直线扫描'); +const rect = { points: [[-4, -3], [4, -3], [4, 3], [-4, 3]], closed: true }; +const rectMesh = buildSweepMesh({ path: line, profile: rect }); ok(rectMesh.tris.length === 36, '矩形截面沿直线扫描并封端'); +const concave = { points: [[0, 0], [4, 0], [4, 1], [1, 1], [1, 4], [0, 4]], closed: true }; +const concaveMesh = buildSweepMesh({ path: { points: [[0, 0, 0], [10, 0, 0]] }, profile: concave }); +ok(closed(concaveMesh) && Math.abs(volume(concaveMesh) - 70) < 1e-4, '凹 L 形截面耳切封端、边闭合且体积正确'); +const arc = { points: Array.from({ length: 17 }, (_, i) => { const a = i * Math.PI / 32; return [50 * Math.cos(a), 50 * Math.sin(a), 0]; }), closed: false }; +ok(buildSweepMesh({ path: arc, profile: circle(3) }).verts.length > 0, '圆截面沿圆弧扫描'); +const polyPath = { points: [[0, 0, 0], [30, 0, 0], [30, 30, 0]], closed: false }; +ok(buildSweepMesh({ path: polyPath, profile: rect }).verts.length > 0, '矩形截面沿多段线扫描'); +const framePath = { points: [[0, 0, 0], [30, 0, 0], [30, 0, 30]], closed: false }; +const fixed = buildSweepMesh({ path: framePath, profile: rect, frameMode: 'fixed' }); +const transported = buildSweepMesh({ path: framePath, profile: rect, frameMode: 'parallelTransport' }); +ok(fixed.verts.some((v, i) => Math.abs(v - transported.verts[i]) > 1e-4), 'fixed 与 parallelTransport 结果不同'); +const twist = buildSweepMesh({ path: line, profile: rect, twistDegrees: 90 }); +ok(Math.abs(twist.verts[13] - rectMesh.verts[13]) > .5, '90° 扭转改变终点截面'); +const pathSketch = { id: 1, plane: 'front', offset: 0, entities: [{ type: 'line', p1: [0, 0], p2: [10, 0] }, { type: 'arc', c: [10, 10], r: 10, a0: -Math.PI / 2, a1: 0 }] }; +ok(samplePathSketch(pathSketch).points.length > 2, '路径草图直线与圆弧采样'); +const unorderedPath = samplePathSketch({ plane: 'front', entities: [{ type: 'line', p1: [10, 0], p2: [20, 0] }, { type: 'line', p1: [0, 0], p2: [10, 0] }, { type: 'line', p1: [20, 0], p2: [20, 10] }] }); +ok(unorderedPath.points[0][0] === 0 && unorderedPath.points.at(-1)[0] === 20 && unorderedPath.points.at(-1)[1] === 10, '乱序连续路径支持链首和链尾拼接'); +const profileSketch = { id: 2, plane: 'front', offset: 0, entities: [{ type: 'rect', c: [0, 0], w: 8, h: 6 }] }; +ok(profileFromSketch(profileSketch).rings.length === 1, '封闭截面草图提取'); +ok(throws(() => buildSweepMesh({ path: { points: [[0, 0, 0]] }, profile: rect }), '至少需要两个'), '单点路径拒绝'); +ok(throws(() => buildSweepMesh({ path: line, profile: { points: [[0, 0], [1, 0]], closed: false } }), '封闭'), '开放截面拒绝'); +ok(throws(() => samplePathSketch({ entities: [{ type: 'line', p1: [0, 0], p2: [1, 0] }, { type: 'line', p1: [3, 0], p2: [4, 0] }] }), '断裂'), '断裂路径拒绝'); +ok(throws(() => buildSweepMesh({ path: { points: [[0, 0, 0], [.1, 0, 0]] }, profile: circle(5) }), '截面尺寸'), '过大截面拒绝'); +let stable = true; const start = performance.now(); for (let i = 0; i < 10; i++) { const m = buildSweepMesh({ path: arc, profile: circle(3) }); stable &&= m.verts.length === 1224 && m.tris.length === 2436; } const elapsed = performance.now() - start; +ok(stable, '重复 10 次构建稳定'); console.log(` 典型圆弧扫描 10 次:${elapsed.toFixed(1)} ms`); +console.log(`结果:${pass} 通过 / ${fail} 失败`); process.exit(fail ? 1 : 0); diff --git a/tests/test.termination.mjs b/tests/test.termination.mjs new file mode 100644 index 0000000..6ff8c0d --- /dev/null +++ b/tests/test.termination.mjs @@ -0,0 +1,51 @@ +import { normalizeTermination, resolveToNext, resolveToFace, buildToBodyTool } from '../js/features/termination.js'; + +let pass = 0, fail = 0; +const ok = (value, message) => { if (value) { pass++; console.log(` ✓ ${message}`); } else { fail++; console.error(` ✗ ${message}`); } }; +const throws = (fn, text) => { try { fn(); ok(false, text); } catch (error) { ok(/[\u4e00-\u9fff]/.test(error.message), text); } }; +const near = (a, b, eps = 1e-7) => Math.abs(a - b) < eps; + +console.log('== M07 拉伸终止条件 =='); +const layers = { + verts: new Float32Array([0,0,5, 10,0,5, 0,10,5, 0,0,10, 10,0,10, 0,10,10]), + tris: new Uint32Array([0,1,2, 3,4,5]), +}; +const next = resolveToNext({ origin: [1, 1, 0], direction: [0, 0, 2], body: layers }); +ok(near(next.depth, 5) && next.faceIndex === 0, 'toNext 沿射线命中首个实体交点'); +const reverse = resolveToNext({ origin: [1, 1, 12], direction: [0, 0, 1], flip: true, body: layers }); +ok(near(reverse.depth, 2), 'flip 后沿反向射线命中首个交点'); +throws(() => resolveToNext({ origin: [20, 20, 0], direction: [0, 0, 1], body: layers }), '无交点给出中文错误'); +throws(() => resolveToNext({ origin: [0, 0, 0], direction: [0, 0, 1] }), '缺失目标实体给出中文错误'); + +const plane = { origin: [0, 0, 8], normal: [0, 0, 2], id: 'top' }; +const face = resolveToFace({ origin: [2, 3, 0], direction: [0, 0, 1], targetFace: plane, offset: 1.5 }); +ok(near(face.exactDepth, 8) && near(face.depth, 9.5), 'toFace 精确计算平面深度和偏置'); +const tilted = { vertices: [[0, 0, 5], [10, 0, 5], [0, 10, 15]] }; +const sloped = resolveToFace({ origin: [2, 2, 0], direction: [0, 0, 1], targetFace: tilted }); +ok(near(sloped.depth, 7), 'toFace 精确命中倾斜三角面'); +throws(() => resolveToFace({ origin: [0, 0, 10], direction: [0, 0, 1], targetFace: plane }), '反方向目标面被拒绝'); +throws(() => resolveToFace({ origin: [20, 20, 0], direction: [0, 0, 1], targetFace: tilted }), '三角面外命中被拒绝'); +throws(() => resolveToFace({ origin: [0, 0, 0], direction: [0, 0, 1], targetFace: null }), '丢失面引用被拒绝'); + +const modes = ['blind', 'throughAll', 'symmetric', 'toNext', 'toFace', 'toBody']; +ok(modes.every(mode => normalizeTermination({ mode, depth: 3, targetFaceRef: 'f1', targetBodyRef: 'b1' }).mode === mode), '六种终止模式可统一解析'); +ok(normalizeTermination({ throughAll: true }).mode === 'throughAll' && normalizeTermination({ symmetric: true, depth: 3 }).mode === 'symmetric', '兼容现有贯穿与对称布尔参数'); +throws(() => normalizeTermination({ mode: 'toFace' }), '缺失到面引用被拒绝'); +throws(() => normalizeTermination({ mode: 'blind', depth: 0 }), '零深度被拒绝'); + +let built = 0, deleted = 0; +const tool = buildToBodyTool({ + getBoundingBox: () => ({ min: [0, 0, 5], max: [10, 10, 15] }), + buildExtrudeTool: (_profile, options) => { built = options.extent; return { delete: () => { deleted++; } }; }, + intersect: (_tool, body) => ({ body, clipped: true }), +}, { profileTool: { origin: [1, 1, 0] }, targetBody: { id: 'body' }, direction: [0, 0, 1] }); +ok(tool.clipped && built > 15 && deleted === 1, 'toBody 使用注入上下文构造并裁剪工具'); +throws(() => buildToBodyTool({}, { profileTool: { origin: [0, 0, 0] }, targetBody: {}, direction: [0, 0, 1] }), '缺失到实体上下文给出中文错误'); + +const started = performance.now(); +let stable = true; +for (let i = 0; i < 1000; i++) stable &&= near(resolveToFace({ origin: [2, 3, 0], direction: [0, 0, 1], targetFace: plane }).depth, 8); +const elapsed = performance.now() - started; +ok(stable && elapsed < 200, `重复解析稳定且 1000 次耗时 ${elapsed.toFixed(2)}ms`); +console.log(`结果:${pass} 通过 / ${fail} 失败`); +process.exit(fail ? 1 : 0); diff --git "a/\345\220\257\345\212\250.bat" "b/\345\220\257\345\212\250.bat" index 6f877ca..b27ff77 100644 --- "a/\345\220\257\345\212\250.bat" +++ "b/\345\220\257\345\212\250.bat" @@ -1,6 +1,6 @@ @echo off title MiniSW Launcher -cd /d "F:\Program Files\MiniSW" +cd /d "%~dp0" echo ======================================== echo MiniSW Launcher @@ -20,23 +20,18 @@ if not exist "%PY%" ( echo Using Python: %PY% echo. -:: If server already running, just open browser -powershell -Command "if(Get-NetTCPConnection -LocalPort 8898 -State Listen -EA SilentlyContinue){exit 0}else{exit 1}" -if %errorlevel%==0 ( - echo Server already running on port 8898. - echo Opening browser... - start "" "http://127.0.0.1:8898/index.html" - timeout /t 2 >nul - exit /b 0 -) +:: Pick the first free port. This avoids accidentally reopening an older MiniSW +:: server that may already occupy 8898 from another directory. +for /f %%P in ('powershell -NoProfile -Command "$p=8898; while($true){try{$l=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,$p); $l.Start(); $l.Stop(); $p; break}catch{$p++}}"') do set "PORT=%%P" echo Opening browser... -start "" "http://127.0.0.1:8898/index.html" +start "" "http://127.0.0.1:%PORT%/index.html" -echo Starting server on port 8898... +echo Serving project: %CD% +echo Starting server on port %PORT%... echo Close this window to stop the server. echo. -"%PY%" -m http.server 8898 +"%PY%" -m http.server %PORT% echo. echo Server stopped. Press any key to close. diff --git "a/\350\275\264\346\211\277\346\224\257\345\272\247_160x70_\317\20668_\317\20640H7.json" "b/\350\275\264\346\211\277\346\224\257\345\272\247_160x70_\317\20668_\317\20640H7.json" new file mode 100644 index 0000000..289f5f3 --- /dev/null +++ "b/\350\275\264\346\211\277\346\224\257\345\272\247_160x70_\317\20668_\317\20640H7.json" @@ -0,0 +1,497 @@ +{ + "app": "minisw", + "version": 2, + "units": "mm", + "name": "轴承支座_160x70_φ68_φ40H7", + "sketches": [ + { + "id": 1, + "name": "底板外轮廓_R12", + "plane": "front", + "planeRefId": null, + "offset": 0, + "entities": [ + { + "type": "line", + "p1": [ + -68, + -35 + ], + "p2": [ + 68, + -35 + ], + "id": 2 + }, + { + "type": "arc", + "c": [ + 68, + -23 + ], + "r": 12, + "a0": -1.5707963267948966, + "a1": 0, + "id": 3 + }, + { + "type": "line", + "p1": [ + 80, + -23 + ], + "p2": [ + 80, + 23 + ], + "id": 4 + }, + { + "type": "arc", + "c": [ + 68, + 23 + ], + "r": 12, + "a0": 0, + "a1": 1.5707963267948966, + "id": 5 + }, + { + "type": "line", + "p1": [ + 68, + 35 + ], + "p2": [ + -68, + 35 + ], + "id": 6 + }, + { + "type": "arc", + "c": [ + -68, + 23 + ], + "r": 12, + "a0": 1.5707963267948966, + "a1": 3.141592653589793, + "id": 7 + }, + { + "type": "line", + "p1": [ + -80, + 23 + ], + "p2": [ + -80, + -23 + ], + "id": 8 + }, + { + "type": "arc", + "c": [ + -68, + -23 + ], + "r": 12, + "a0": 3.141592653589793, + "a1": 4.71238898038469, + "id": 9 + } + ], + "constraints": [], + "dims": [] + }, + { + "id": 11, + "name": "四个安装孔_孔距120x30", + "plane": "front", + "planeRefId": null, + "offset": 0, + "entities": [ + { + "type": "circle", + "c": [ + -60, + -15 + ], + "r": 6.5, + "id": 12 + }, + { + "type": "circle", + "c": [ + -60, + 15 + ], + "r": 6.5, + "id": 13 + }, + { + "type": "circle", + "c": [ + 60, + -15 + ], + "r": 6.5, + "id": 14 + }, + { + "type": "circle", + "c": [ + 60, + 15 + ], + "r": 6.5, + "id": 15 + } + ], + "constraints": [], + "dims": [] + }, + { + "id": 17, + "name": "立板_26x46", + "plane": "front", + "planeRefId": null, + "offset": 14, + "entities": [ + { + "type": "rect", + "c": [ + 0, + 0 + ], + "w": 26, + "h": 46, + "id": 18 + } + ], + "constraints": [], + "dims": [] + }, + { + "id": 20, + "name": "圆筒座_φ68_φ40", + "plane": "right", + "planeRefId": null, + "offset": -30, + "entities": [ + { + "type": "circle", + "c": [ + 70, + 0 + ], + "r": 34, + "id": 21 + }, + { + "type": "circle", + "c": [ + 70, + 0 + ], + "r": 20, + "id": 22 + } + ], + "constraints": [], + "dims": [] + }, + { + "id": 26, + "name": "前加强筋_三角截面", + "plane": "top", + "planeRefId": null, + "offset": -27, + "entities": [ + { + "type": "line", + "p1": [ + -13, + 14 + ], + "p2": [ + 0, + 14 + ], + "id": 27 + }, + { + "type": "line", + "p1": [ + 0, + 14 + ], + "p2": [ + 13, + 14 + ], + "id": 28 + }, + { + "type": "line", + "p1": [ + 13, + 14 + ], + "p2": [ + 0, + 66 + ], + "id": 29 + }, + { + "type": "line", + "p1": [ + 0, + 66 + ], + "p2": [ + -13, + 14 + ], + "id": 30 + } + ], + "constraints": [], + "dims": [] + }, + { + "id": 32, + "name": "后加强筋_三角截面", + "plane": "top", + "planeRefId": null, + "offset": 19, + "entities": [ + { + "type": "line", + "p1": [ + -13, + 14 + ], + "p2": [ + 0, + 14 + ], + "id": 33 + }, + { + "type": "line", + "p1": [ + 0, + 14 + ], + "p2": [ + 13, + 14 + ], + "id": 34 + }, + { + "type": "line", + "p1": [ + 13, + 14 + ], + "p2": [ + 0, + 66 + ], + "id": 35 + }, + { + "type": "line", + "p1": [ + 0, + 66 + ], + "p2": [ + -13, + 14 + ], + "id": 36 + } + ], + "constraints": [], + "dims": [] + } + ], + "references": [], + "dimensions": [ + { + "name": "BaseLength", + "expression": "160", + "unit": "mm" + }, + { + "name": "BaseWidth", + "expression": "70", + "unit": "mm" + }, + { + "name": "BaseHeight", + "expression": "14", + "unit": "mm" + }, + { + "name": "BaseCornerR", + "expression": "12", + "unit": "mm" + }, + { + "name": "MountHoleDia", + "expression": "13", + "unit": "mm" + }, + { + "name": "MountHoleX", + "expression": "60", + "unit": "mm" + }, + { + "name": "MountHoleY", + "expression": "15", + "unit": "mm" + }, + { + "name": "StandThicknessX", + "expression": "26", + "unit": "mm" + }, + { + "name": "StandWidthY", + "expression": "46", + "unit": "mm" + }, + { + "name": "StandHeight", + "expression": "22", + "unit": "mm" + }, + { + "name": "BossOuterDia", + "expression": "68", + "unit": "mm" + }, + { + "name": "BossBoreDia", + "expression": "40", + "unit": "mm" + }, + { + "name": "BossLengthX", + "expression": "60", + "unit": "mm" + }, + { + "name": "BossCenterZ", + "expression": "70", + "unit": "mm" + }, + { + "name": "BoreChamfer", + "expression": "2", + "unit": "mm" + }, + { + "name": "RibThickness", + "expression": "8", + "unit": "mm" + } + ], + "parameters": {}, + "assembly": null, + "drawings": [], + "features": [ + { + "id": 10, + "name": "底板_160x70x14", + "type": "extrude", + "sketchId": 1, + "params": { + "depth": 14 + } + }, + { + "id": 16, + "name": "四个φ13安装通孔_边距20", + "type": "cut", + "sketchId": 11, + "params": { + "throughAll": true, + "mode": "throughAll" + } + }, + { + "id": 19, + "name": "立板_厚26_宽46_高22", + "type": "extrude", + "sketchId": 17, + "params": { + "depth": 22 + } + }, + { + "id": 23, + "name": "圆筒座_外φ68_内φ40_长60", + "type": "extrude", + "sketchId": 20, + "params": { + "depth": 60 + } + }, + { + "id": 24, + "name": "内孔左端_C2", + "type": "cylcut", + "sketchId": null, + "params": { + "r": 22, + "r2": 20, + "h": 2, + "axis": "x", + "tx": -29, + "tz": 70 + } + }, + { + "id": 25, + "name": "内孔右端_C2", + "type": "cylcut", + "sketchId": null, + "params": { + "r": 20, + "r2": 22, + "h": 2, + "axis": "x", + "tx": 29, + "tz": 70 + } + }, + { + "id": 31, + "name": "前加强筋_厚8", + "type": "extrude", + "sketchId": 26, + "params": { + "depth": 8 + } + }, + { + "id": 37, + "name": "后加强筋_厚8", + "type": "extrude", + "sketchId": 32, + "params": { + "depth": 8 + } + } + ] +} \ No newline at end of file