diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/CAD_CODER_INTEGRATION_EVALUATION.md b/server_patch/ai_3d_model/ai-solid-wood-cost/CAD_CODER_INTEGRATION_EVALUATION.md new file mode 100644 index 0000000..d95d204 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/CAD_CODER_INTEGRATION_EVALUATION.md @@ -0,0 +1,53 @@ +# CAD-Coder 对产品成本评估 CAD 链路的适用性评估 + +## 结论 + +CAD-Coder 可以加强“单个已隔离部件的参数化几何候选生成”,不能直接代替整件家具的多视图理解、装配图谱、五金/孔槽规则和生产验证。线上主链路不应直接使用其单次输出。 + +## 论文中已证明的能力 + +- 训练目标是从一张渲染图输出可执行的 CadQuery Python,训练集为 163,671 个图像-代码配对。 +- 数据中的几何操作主要是直线、圆弧、圆和拉伸;训练图为理想化灰度等距渲染。 +- 对 100 个同分布测试样本,论文报告可执行率 100%、尺度和位移归一化后的最佳对齐 IoU 为 0.675。 +- 论文只用 5 个简单 3D 打印物体检查真实照片泛化,结果显示可生成大致形状,但比渲染图明显更差。 + +## 不能证明的能力 + +- 没有家具总成、抽屉、移门、背板、五金、连接孔、槽位和 BOM 语义评测。 +- 没有同时输入我们要求的正面、侧面、内部/开门和尺寸图并做一致性求解。 +- IoU 评测先归一化尺度并选最佳主轴对齐,因此不能证明实际长宽高和方向正确。 +- 论文明确报告真实照片中会出现长宽比例错误,多次拉伸的复杂实体会漏特征,未训练操作的成功很依赖提示词。 +- 该代码库尚未提供简化的单请求在线推理接口;模型权重约 26.7GB,官方推理代码依赖 CUDA/LLaVA 环境。 + +## 正确的接入位置 + +1. 现有多视图理解器先产生带证据的家具装配图谱:总成、物理实例、尺寸参数、运动副和不确定项。 +2. 确定性约束编译器解决坐标、边界、接触、干涉和数量,不接受模型心算 XYZ。 +3. 只将已隔离且普通 box 表达不足的单个异形件裁剪图交给 CAD-Coder,让它生成候选 CadQuery 实体。 +4. 生成代码在无网络、无宿主文件权限、有超时和资源额度的沙箱执行。 +5. 每个候选必须通过尺寸包围盒、体积、面数、连通体、与相邻件接触/干涉、四向轮廓和孔槽清单检验。 +6. 只有确定性检验通过才将候选合并到主图谱;失败则回到局部重读/重生成,不会交付给用户。 +7. 连接孔、背板槽、抽底槽、螺钉和滑轨仍由“材料 + 板厚 + 接触类型 + 运动副 + 载荷”的工艺模板生成,禁止 CAD-Coder 自由猜测。 + +## 训练和评测改造 + +- 学习论文的领域专项微调,但目标不应只是最终单实体 CadQuery,而是“装配图谱 + 可执行局部几何程序”。 +- 用现有参数化家具图谱自动生成正视/侧视/俯视/轴测/尺寸图,并保留完整部件、接触、孔槽和材料真值。 +- 在渲染图基础上增加真实商品背景、透视、遮挡、纹理、弱标注和多视图缺失,不重复 CAD-Coder 的理想灰度等距训练分布。 +- 评测不使用归一化后最佳方向 IoU 作唯一指标;必须同时验收绝对 X/Y/Z、部件数量、结构图谱、连接模板、孔槽和冻结后成本偏差。 +- 公司 BOM 只在估算冻结后做盲测答案键,不进入识图、生成或自修正上下文。 + +## 资源与上线边界 + +- 权重已发布,并非纸面方案。但现有代码默认 `.half().cuda()`,当前 Apple M1 Max 开发机不能按官方路径原样运行。 +- 不在 Chat API 服务主机内下载 26.7GB 权重或与在线任务抢占显存/内存。先在独立 GPU 工作节点做隔离 A/B 评测。 +- 首批验收不是“代码可运行”,而是同一原图下相对现有候选生成器在部件完整率、绝对尺寸、接触干涉、孔槽和最终成本上确实提升。 + +## 参考 + +- CAD-Coder 论文: +- CAD-Coder 官方代码: +- CAD-Coder 官方权重: +- 2026 CADBench: +- Ortho2CAD: + diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/GENCAD_INTEGRATION_EVALUATION.md b/server_patch/ai_3d_model/ai-solid-wood-cost/GENCAD_INTEGRATION_EVALUATION.md new file mode 100644 index 0000000..6c19e66 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/GENCAD_INTEGRATION_EVALUATION.md @@ -0,0 +1,103 @@ +# GenCAD 项目接入评估 + +## 2026-09-07源码复核快照 + +- MIT研究仓库复核提交:`f5484cf90e30d15b980313006a1424f8949b3496`(2025-07-14)。 +- `gurul/gencad`工具链复核提交:`364770c8f09792064389e7abc85240813622d120`(2026-08-27)。 +- MIT仓库的正式`inference_gencad.py`逐个遍历PNG,每张图片独立编码并生成一个CAD;它没有调用仓库中仅定义、未被其他代码引用的`MultiViewResNetImageEncoder`。因此不能把“代码里有多视图类”理解成已实现家具多视角融合。 +- MIT仓库的命令词汇和实现常量共同限定为直线、圆弧、圆、草图循环和拉伸,`MAX_TOTAL_LEN=60`、`MAX_N_EXT=10`。没有装配体、零件实例、运动副、五金和制造特征语义。 +- MIT仓库README的Evaluation仍标记为`Coming soon`;论文指标来自作者论文,仓库没有现成的家具实拍、多视图、BOM或生产孔槽复现实验。 +- MIT仓库根目录没有LICENSE文件。论文公开和代码可下载不等于允许商用;授权未明确前只能隔离研究,不进入生产依赖和训练资产。 +- `gurul/gencad`根仓库为MIT,但其中包含AGPL子模块和独立许可的vendored代码,使用时必须逐组件合规,不能把整个依赖树视为MIT。 + +## 同名项目必须区分 + +### MIT GenCAD 研究 + +项目是图像条件的参数化 CAD 程序生成模型,组合了: + +1. 自回归 CAD 命令编码/解码; +2. 图片与 CAD 程序的对比学习共享表征; +3. 图片条件的潜空间扩散; +4. CAD 命令序列解码。 + +论文使用过滤后的 DeepCAD 数据:168,674 个有效 CAD,152,530/8,515/7,629 作为训练、验证和测试,生成 845,105 张灰度图片。五个版本主要是 X/Y/Z 缩放变体,不是产品多视角证据。 + +它的命令词汇只有直线、圆弧、圆、循环起点、序列终点和拉伸,最大 10 次拉伸、总序列长度 60。没有装配语义、家具部件实例、五金、连接孔槽规则、材料和工艺成本。 + +论文明确列出的限制包括:工业 CAD 任务更复杂;缺少旋转、圆角、倒角等操作;不能保证输出有效;训练图主要是干净等轴 CAD 图,真实照片、噪声背景和非等轴视角需要后续研究。论文失败样例含相交面、不规则实体和不连通实体。 + +研究仓库当前没有可见的 LICENSE 文件,代码或权重用于商业产品前必须先明确授权。 + +### gurul/gencad 工具链 + +这是另一个项目:把无头 FreeCAD 暴露给编程代理,并提供截面渲染和等轴渲染。最值得借鉴的是: + +- build → render → inspect → repair 的闭环; +- 用真实几何内核生成截面图,检查孔、槽、壁厚和间隙; +- 用对象位移生成炸开视图; +- 最终保留 FCStd/STEP 供人工测量和复核。 + +不能原样接入生产。其 freecad_run 接受任意绝对 Python 脚本路径,以完整宿主环境执行;没有允许目录、环境变量清理、容器隔离或文件/网络能力限制,只有超时。截面工具也允许调用方指定任意输入输出绝对路径。 + +主仓库为 MIT,但包含独立许可的子模块;不能把整个目录一概按 MIT 处理。 + +## 对当前产品成本评估的价值 + +### 值得采用 + +1. 图片—CAD 共享向量检索:用产品局部图从经过审核的历史零件库检索几何模板,只作为候选,不读取对应公司成本答案。 +2. 多候选生成:同一低置信零件生成多个参数化候选,由尺寸、连接、干涉和视觉一致性共同排序。 +3. 截面复核:在背板槽、抽屉底板槽、排钻孔、偏心件孔和金属管壁位置自动切片,直接验证实体内部结构。 +4. 闭环修复:失败反馈必须定位到零件、轴、孔槽或连接面,只允许局部补丁。 +5. 炸开视图:继续使用结构图谱中的稳定零件 ID 和位移向量,生成可复核的装配/炸开/包装三态。 + +## 应该如何落到我们的架构 + +GenCAD不应成为“原图直接输出最终家具CAD”的黑盒。可复用的思想应拆成四层,并由现有结构图谱保持唯一事实源: + +1. **候选检索层**:按局部图像、轮廓和尺寸约束从经过人工验收的零件模板库检索top-k;只返回候选几何/连接模板及相似度,不返回公司同款BOM数量或成本答案。 +2. **多候选图谱层**:对低置信结构生成2—4个完整装配图谱候选,不直接生成互不关联的实体;每个候选都必须满足原图证据、整体尺寸、角色数量契约和材料工艺约束。 +3. **确定性几何裁判层**:统一执行参数依赖重算、装配边界、真实接触、B-rep闭合、正体积干涉、孔槽边界、连接件唯一计数和关键截面检查。硬门禁失败的候选不能进入视觉评分。 +4. **独立视觉裁判层**:把原始多视图与CAD正面/侧面/俯视/等轴及关键截面同时给复核模型;视觉模型只输出定位到稳定零件ID的差异,不直接改成本或宣告可生产。 + +候选排序建议使用可审计的分层门禁,而不是一个加权总分掩盖致命错误: + +`证据/拓扑硬门 → 毫米尺寸硬门 → B-rep/孔槽/干涉硬门 → 多视图一致性 → 不确定性数量 → 模型置信度` + +只有前三级全部通过的候选才允许进入后两级;否则即使渲染外观很像也必须失败。 + +## 不能直接接入生产的原因 + +| 能力 | MIT GenCAD | 家具成本评估需要 | 判断 | +| --- | --- | --- | --- | +| 输入 | 单张干净等轴CAD图/草图 | 3张实拍+尺寸图,跨视图同件追踪 | 不满足 | +| 输出对象 | 单个CAD程序/实体 | 多零件装配图谱+BOM+运动关系 | 不满足 | +| 几何词汇 | 草图+拉伸为主 | 板件、管件、孔槽、封边、焊缝、五金 | 不满足 | +| 正确性 | 论文明确不能保证有效CAD | 任一硬门失败不得报价完成 | 不满足 | +| 评价 | 分布级COV/MMD/JSD/FID | 每件产品毫米误差、BOM精确率、连接可制造性 | 指标不等价 | +| 商用许可 | 仓库未见LICENSE | 生产依赖必须有明确授权 | 当前阻塞 | + +因此接入策略是:**借鉴方法,不直接部署MIT模型;借用`gurul/gencad`的截面复核思路,但使用我们已有的隔离CAD工作者和类型化接口重写,禁止任意脚本路径执行。** + +### 不应采用 + +- 不把 MIT GenCAD 当成完整家具图像到 CAD 的主模型。 +- 不用它决定 BOM 数量、抽屉排列、移门/铰链、材料和工艺。 +- 不直接把 gurul/gencad 的任意脚本执行 MCP 暴露给 Chat API。 +- 不以“渲染像”或“STEP 可打开”代替毫米尺寸和可制造性门禁。 + +## 推荐优先级 + +1. 现有结构图谱继续作为装配、BOM 和连接关系的唯一权威中间表示。 +2. Zero-to-CAD 作为孔槽/圆角/异形等单零件几何候选器。 +3. 在隔离的可信 FreeCAD/OCCT 工作者中增加截面几何复核器。 +4. 采用 GenCAD 的对比检索思路建设公司历史零件模板检索,但在生成结果冻结后才做成本对照。 +5. CAD-Coder 和 MIT GenCAD 仅作为额外候选与离线基准。 + +## 安全接入边界 + +- 只允许服务端生成的受信脚本进入几何工作者。 +- 工作者运行于容器/沙箱,禁网、只读基镜像、限定 CPU/内存/超时和输出目录。 +- 所有输入、输出和图谱哈希进入审计记录。 +- 截面图与原图/CAD 多视图一起交给独立复核模型;任何硬门禁失败都不得生成报价完成状态。 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/STRUCTURE_GRAPH_UPGRADE.md b/server_patch/ai_3d_model/ai-solid-wood-cost/STRUCTURE_GRAPH_UPGRADE.md new file mode 100644 index 0000000..f4e70e7 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/STRUCTURE_GRAPH_UPGRADE.md @@ -0,0 +1,70 @@ +# 通用多视图到家具CAD算法升级(候选,未上线) + +## 为什么停止继续堆中文名称修正规则 + +2026-09-07同一批原图的v78隔离试验说明,仅追加“先识别结构”提示仍不够: + +- 书架专项读图仍把侧板认成连续整板;总体宽度复读更正后,基于旧宽度算出的板件没有联动重算。 +- 中文尺寸证据“总宽减去两侧板厚”被旧规范化器同时当成宽度和厚度。 +- 餐柜中的“抽屉中竖板、抽屉下横档”又被宽泛的中文抽屉判断当成面板,真正面板可能被数量截断器丢掉。 + +这些不是两个SKU的例外,而是数据表示缺陷:自由文本承担了几何语义、结构身份和公式依赖,多个后处理器反复猜测并覆盖信息。不能继续仅按案例名加条件。 + +## 新的唯一结构来源 + +`structure_graph.py`候选编译器使用明确的数据: + +1. **观察证据**:原图ID、局部区域、观察文字、来源类型、置信度。来源必须与本轮真实原图集合一致;用户确认只能来自会话事实库,模型无权自行声明。 +2. **参数依赖图**:尺寸类型、单位、来源、证据;派生值保存算式,不保存一句“减去板厚”作为可执行规则。支持依赖重算、量纲检查、成环检查。未知值不补成默认18mm。 +3. **零件定义**:稳定ID、局部尺寸、几何类型。中文名仅用于显示,不决定轴向和结构身份。 +4. **物理实例**:每件板的独立ID、所属总成和明确轴映射。每个总成先声明由尺寸证据或派生参数定义的局部`bounds_mm`;AI不再直接输出XYZ,而是给出相对所属总成、产品外形或其他实例的min/center/max锚点约束,由编译器求解坐标。BOM数量从实例生成,不由模型再给一套可独立漂移的数量。 +5. **连接关系**:同一总成内固定/支承接触从最终几何的正面积相交自动推导,不再让AI猜面。抽屉、门等跨总成的滑动/铰接关系显式声明,并由后续五金模板复核。 +6. **几何反馈**:越界、重复实体、无实例部件、数量冲突、连接错位产生可定位到图谱路径的错误,交还AI修改;不能直接翻译成让用户修图的问卷。 + +同一图谱编译结果生成CAD草案和BOM。候选`manufacturing_oracle.py`已把编译后的真实接触面转换为孔、槽、焊缝和唯一五金实例;孔槽、五金模板、成品下料尺寸与后续工时台账都以同一`connection_id`、`instance_id`和`hardware_instance_id`追溯,不再按中文名称独立猜数量。 + +## 当前已实现的边界 + +- 已有标准库实现的参数解析器、量纲校验、实例/BOM生成、固定接触校验、包围尺寸校验、重复实体拦截。 +- 已有独立试验脚本`probe_structure_graph.py`:原始四图→AI图谱→编译→实际STEP/GLB/预览→AI对照原图复核→最多两轮反馈修正。使用隔离状态目录,不碰真实用户会话,也不读公司BOM。 +- 随机尺寸/板厚依赖重算、单位等价、中文名称不改变几何等变换测试已加入。它们只验证编译规则,不证明AI已正确识图。 +- 当前只支持正交box板件;已增加AABB实体穿插、总成局部边界和滑动总成非运动轴范围检查。候选制造层已支持人造板确认螺钉、层板托、捕获槽、木螺钉、混合材料螺栓和金属焊缝的基础几何驱动,但供应商专用孔模板、铰链/滑轨运动包络、曲面、金属型材、任意角度和连续运动扫掠碰撞仍未迁移。对不支持的形状或连接报错,不偷偷换成长方体或虚构孔位。 +- 背板和抽屉底板的捕获槽同时生成承载件槽体和嵌入板下料余量;制造BOM采用“净接触几何+两端入槽深度”,CAD用调整后的板件真实伸入槽内,再由B-rep布尔干涉检查验证,不再出现“槽切了但板停在槽口”的假连接。 +- 通用CAD构建链在单体闭合性检查后增加零件两两正体积干涉门,未通过时禁止预览和成功状态。服务器现有检查器使用`--format json`,不能使用不存在的`--json`参数。 +- 所有输出均为drawing_draft;编译通过也必须原图视觉复核和制造验证,不能直接下料。 +- **当前尚未替换生产Chat API主链路,也没有达到公司成本误差小于5%的验收目标。** + +## v85隔离内核证据(2026-09-07) + +- 本地完整成本/图谱/制造回归172项通过(253.707秒);加入捕获槽成品尺寸和证据文件语义角色后,图谱、制造与CAD管线39项专项回归通过。 +- 服务器`/tmp`隔离运行确认螺钉样例:3个实体、12个真实孔、6个唯一连接件,STEP为101927字节;切孔前后面数18→36、总体积21340800→21335663.496mm³,减少量与孔模板理论切削体积一致;3对实体B-rep干涉检查0冲突。 +- 捕获槽样例:两侧各6mm入槽,抽屉底板成品尺寸由764×400×18变为776×400×18mm,CAD为3个实体、26个面、总体积21338400mm³;2个槽的0.5mm装配余量真实保留,3对实体0冲突。 +- 包装爆炸模型用当前11个包装节点实测55对候选,其中4对进入布尔检查、51对由包围盒排除、0冲突,说明新增干涉门没有误伤当前包装模型。 +- 上述只是候选文件和服务器临时目录的真实几何证据;没有覆盖生产文件、没有重启服务、没有完成原图识别或成本5%盲测。 + +## v87 GenCAD方法落地与隔离失败证据(2026-09-07) + +- MIT GenCAD 与同名 `gurul/gencad` 的源码级审计见 `GENCAD_INTEGRATION_EVALUATION.md`。本项目只吸收多候选、几何执行、截面/多视图复核和有界修复方法;不把任一仓库直接暴露给 Chat API,也不把单零件生成器当作家具装配真值。 +- 编译器允许实例把锚点写成“自身所属总成的稳定 ID”,内部仍归一为总成局部坐标;引用其他总成不会被静默接受。运动连接误用零件实例时返回实例→所属总成映射及独立运动总成修复要求。 +- CAD 内核碰撞与非法 JSON Patch 已转换为稳定实例 ID、重叠体积/范围和合法替换路径,失败不会吞成笼统异常,也不会无限重试。 +- 本地结构图谱、制造层与 CAD 管线专项回归 44 项通过;最终候选代码的成本、结构图谱、制造层与 CAD 管线完整回归 180 项通过(253.928 秒)。这些测试证明门禁和既有计算未回退,不代表真实读图已经验收通过。 +- 服务器 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard4` 隔离试验真实读取 4 张原图,独立固定 top=1、bottom=1、side=2、door=2、shelf=1、leg=4 及 1200×400×800mm 外形,且未读取公司 BOM。三轮图谱 SHA 均变化,证明模型确实处理了反馈;但编译问题为 60→55→59,B-rep 冲突为 3→6→5,最终 `graph_and_cad_kernel_not_passed`。 +- 本次失败的主要新证据是板件局部轴/总成边界修复仍不稳定:门板的宽高厚轴被错置,背板沿深度轴穿越柜体,第三轮又给多块板统一加入 20mm 正偏移。门、背板与侧板因此超界并发生正体积相交。门禁已正确拒绝交付,但 GenCAD 风格的自由补丁尚不能收敛为制造模型。 +- 上述隔离进程已结束,未覆盖生产文件、未重启服务、未改变真实用户会话;因此当前仍是候选能力,不能标记上线或完成成本误差≤5%目标。 + +## 下一阶段交付门禁 + +- 给尺寸和接缝定位增加局部证据复读,AI必须引用对应区域,避免整图一句“连续无缝”高置信覆盖局部事实。 +- 默认由AI完成低置信复读:先逐张读图、再独立读尺寸箭头、再用CAD正面/侧面/俯视/等轴四视图回看原图。用户确认只保留给完全不可见且成本敏感的残余冲突,不得把每个置信度不足字段做成问卷。 +- 图谱修改应局部、版本化;每轮保存图谱哈希、修改路径、编译反馈、渲染及视觉差异。错误相同且两轮未改善时停止自动循环,只询问真正缺少的关键事实,不重复整页。 +- 把材质、板厚等交互转成参数变更,完整重算图谱,再编译CAD、孔槽、五金和成本。不能在图谱编译后再调用旧名称启发式修改几何。 +- 扩充不同结构的留出集:开放架、抽屉柜、移门柜、桌架组合、板金属组合等;变更板厚、整体尺寸、抽屉排列、层板数量都做一致性检验。两款用户案例不等于覆盖所有家具。 +- 冻结图谱/CAD/BOM和报价后,再读取公司同款成本评价。公司数据用于基准单价和事后偏差审计,不给视觉模型提供目标BOM。 +- 真实Chat API验收需明确实际运行版本、全程中文进度、无重复提问/计费、可修改前序参数,并核对每个交付文件;不能把隔离模型请求当作完整API验收。 + +## 同行公开资料的可用结论 + +Zoo公开说明其代理会操作可编辑的参数化模型,执行几何代码,读取质量/体积等模型信息,并用多视图快照反复检查。我们借鉴“结构表示+几何执行反馈+视觉反馈”机制,不能据其宣传推断多张家具图片已经可以稳定得到生产级完整BOM,也不能据此承诺成本误差5%。 + +- [Zoo Zookeeper技术说明](https://docs.zoo.dev/research/zookeeper) +- [Zoo Zookeeper使用说明](https://docs.zoo.dev/docs/zoo-design-studio/zookeeper) diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md new file mode 100644 index 0000000..0595116 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/VALIDATION_LEDGER.md @@ -0,0 +1,225 @@ +# 产品成本评估:CAD / BOM / 成本盲测台账 + +## 不可违反的验收原则 + +- 估价器只读取用户提供的图片、尺寸、材料与工艺确认;被测公司的 SKU、BOM、单价和总成本在报价冻结前不可读取。 +- 公司 BOM 只作为报价后的答案键和公共物料价格统计,不可用同款 SKU 反向覆盖本次部件数量、尺寸或总价。 +- 数据划分必须以 SPU 为组,避免同一产品不同 SKU 分别落入校准集和盲测集。 +- 每次模型或规则升级都必须重放固定盲测集,并新增至少一个此前未见的结构族样本。 +- 目标同时满足:必需部件数量完全一致、不得出现不存在的部件、关键尺寸在确认公差内、连接工艺与材料相容、总成本误差绝对值不超过 5%。总价接近但 BOM 错误仍判失败。 + +## 统一评测指标 + +| 维度 | 硬性指标 | 失败示例 | +| --- | --- | --- | +| 输入质量 | 每张图有角色、质量、置信度、替换关系;缺图只补传该角色 | 用附件数量代替角色验收 | +| 结构 BOM | 必需部件 role + side + instance 数量完全一致 | 两个抽屉只有一组左右帮板 | +| 几何 | 整体外包围盒按确认尺寸;板件厚度和轴向语义一致 | 门板宽高互换、薄背板被统一加厚 | +| 拓扑 | 无非法重叠、悬空、越界;门/抽屉/层板与开口匹配 | 移门门扇不覆盖门洞 | +| 制造特征 | 孔、槽、封边、焊点均有几何证据和唯一成本驱动 | 零孔槽仍计 CNC;孔位和人工重复计费 | +| 五金 | 由连接图和运动副反推规格、数量及孔模板 | 移门误加铰链;五金按部件数套默认包 | +| 成本 | 总成本相对公司答案键绝对误差 ≤5%,并报告分类误差 | 用公司同款总价或 BOM 行直接修正输出 | +| 图纸 | 特征表可分页且无截断;低置信特征必须阻止正式发布 | 特征 24 条但图纸只显示 23 条 | + +## 已完成且不得回退的通用修复 + +- 图片按正视、侧视、开门/内部、尺寸图逐张验收,支持单张补传和替换。 +- CAD 后再次使用原始多视图与组装/炸开渲染图复核;AI 意见必须经过尺寸账本和确定性几何门裁决。 +- 用户确认的拓扑和尺寸在统一板厚后重新应用,防止通用柜体重排覆盖分段侧板。 +- 移门采用产品轴向尺寸账本,禁止根据整体比例猜测并交换门板宽高。 +- 公司历史 BOM 在盲测估价中关闭;盲测完成后再加载答案键。 +- 零孔槽不计 CNC、成品饰面板不重复计贴面/油漆、包装材料损耗固定为 2%,包材与人工使用不同成本行。 + +## 固定回归样本 + +| 样本 | 已验证事实 | 当前结果 | 尚未证明 | +| --- | --- | --- | --- | +| 花城 1200 推拉餐柜 | 1200×400×850,双移门、双抽屉;其他结构仍须按原始资料核验 | 旧显式结构回放曾有23个实例;不等于首次读图或盲测通过 | 与该款完整公司 BOM 的逐行结构/成本误差 ≤5% | +| 花城模块书架(旧称480,宽度答案键待核实) | 高1600、下柜深300可由参考图验证;线稿48是下模块竖向高度,不是整体宽 | 旧“480×300×1600、6分段侧板已通过”仅属显式约束回放,不能作为盲测正确证据 | 图中真正整体外宽、合成参考图与线稿的背板/侧板接缝一致性、逐件结构与成本 | +| SZ001514N886V01 板木金属办公桌 | 公司答案键 123.51 元 | 2026-09-04 旧盲测为 311.48 元(+152.19%) | 必须用当前版本重新跑;旧结果不可作为已修复证据 | + +## 已否决方案(避免重复) + +- 只依赖单次多模态模型直接输出 CAD/BOM:缺少尺寸和拓扑约束,结果不可稳定制造。 +- 只比较渲染图外观:可以掩盖重复板、缺失抽屉帮板、错误孔槽和错误五金。 +- 为单一产品硬编码数量/尺寸:只能让回归样本通过,无法证明未来产品泛化。 +- 为达到 5% 直接读取公司同款 BOM 或总成本:这是答案泄漏,不是成本评估能力。 +- 只以总价误差验收:不同类别高低相互抵消时会产生“价格正确、结构错误”的假通过。 + +## 下一阶段顺序 + +1. 从 21,695 个公司 SKU 中按结构族抽取有完整图片、尺寸图和成本答案键的 SPU 级盲测集。 +2. 将识别输出固定为参数化形状程序:部件、轴向尺寸、装配关系、运动副、连接面和制造特征。 +3. 增加二维投影差、轮廓差、遮挡次序和多视图一致性评分;低分先由AI针对原图疑点重读、修正并复核,确实无资料或仍冲突时才集中询问用户。 +4. 补齐五金供应商模板与生产图分页,只有通过自动门和人工签批才能从 `drawing_draft` 升级。 +5. 每轮输出逐样本指标、宏平均、最差样本与失败原因;未达到全部门槛不得标记完成。 + +## 2026-09-07 v62 新增通用规则 + +- 当前用户消息中明确标注的整体/外形/成品尺寸,是本轮产品外包围的最高优先级证据;尺寸图二次转录不得用相邻规格或多规格表覆盖。 +- “桌面、台面、tabletop、desktop、顶板”统一映射为顶面角色,尺寸表只允许更新同一制造部件,不得额外生成重复顶板。 +- 只有用户明确外包围契约生效时,才对越界板件执行硬约束;不得把这一约束错误施加到独立型材下料长度等通用 BOM 数据。 +- 生产工艺特征表超过单页容量时必须续页,不能截断,也不能因版面溢出把已完整的工艺数据判为生产阻塞。 + +## 2026-09-07 v63 新增通用规则 + +- “左右各 N 块层板/搁板”必须解释为总数 `2×N`;后续纠正语句里的单侧数量不得把已经确认的总数降为 N,只有“共/合计/改为/实际只有”等明确总量修正可以覆盖。 +- 抽屉后帮板必须封闭在左右帮板的尾端,不得简单贴到柜体后沿;抽屉深度小于柜深时仍要保持箱体闭合。 +- 多模态复核对“抽屉穿出柜体”的判断必须与CAD实例的六面边界逐轴核验;只有所有抽屉箱体实例都处于产品外包围内才允许反证该说法。 +- 仅凭抽屉部件深度小于柜深,不能反证位置错位、装配冲突、干涉或重叠;这类空间问题必须保留给确定性几何门或用户确认。 + +## 2026-09-07 v64 新增通用规则 + +- 报价完成后必须把“实际用于生成CAD和计价”的分析版本写回项目;不得让会话检查点继续保存统一板厚前的旧BOM,造成已交付CAD为12mm而查询结果仍显示10mm。 +- 抽屉面板高度在多视图重读后发生变化时,抽屉间竖隔板必须随面板区高度重新计算;旧高度不得把隔板推到产品外包围之外并反复触发CAD复核。 +- “底板承托/前板位置”类AI复核项只有在五件抽屉箱体逐轴闭合、全部处于产品边界内且前/左/右/后四面均存在有效底板槽时才可确定性反证;缺任一条件都必须继续阻断。 + +## 2026-09-07 v65 新增通用规则 + +- 带尺寸箭头的侧面/三分之四视图,只要模型确认展示产品前后深度,不得仅因存在标注而改判为尺寸图;否则会占用尺寸角色并错误索要用户重复补传侧视图。 +- 用户同轮已明确完整外包围时,详细图若提供了至少两类内部/抽屉/门片尺寸,不得只因缺少某个重复的整体尺寸标签而判废。 +- 真实流程验收必须绑定本次附件创建的项目,且最终响应和项目阶段都为成功;禁止按产品名回退读取历史已完成项目制造假阳性。 +- 已确认的模块化侧板在CAD复核重识别后必须按段恢复,不能退化成两块整高侧板;带R角的侧板轮廓不得再额外生成“圆角装饰条”重复部件。 +- 上部层板比下柜窄的阶梯式书架,分段侧板的中/上段必须按层板内宽居中内收;不得把所有分段仍放在下柜最外侧。 + +## 2026-09-07 v67-v68 新增通用规则 + +- 中文结构约束不能依赖单一书面句式:“每侧分3段”“每侧分为三段”“每侧分成3段”必须归一到同一拓扑;“底部仅2个上下抽屉”优先于同句中“上部5块层板”,不得被邻近的“左右侧板”误判成顶部并排抽屉。 +- 未给出每段高度但已有下部功能区高度和产品总高时,可按已知下段高度与剩余净高等分生成带来源的CAD估算;这些数值必须保留估算标签并进入多视图复核,不能伪装为图片实测。 +- 上传图片时一次性授权“未标注内部尺寸按CAD装配合理值预估”后,系统应自动补齐最小承重外壳并进入CAD复核;不得再次逐项索要抽屉帮板或底板尺寸。整体长宽高、门型等仍无法从图片确定且显著影响成本时才询问。 +- 花城样品改为盲测:上传文件名中不写产品类型、尺寸、门型,提示词不提供标准BOM;标准结构只在完成后评分。人工补充答案和AI/CAD自修正必须分别计数,禁止把用户补充后的结果冒充首次读图准确率。 +- Zoo/Zookeeper公开方法的可复用核心不是“让通用模型一次猜CAD”,而是严格上下文的代理循环:计划→执行参数化几何→调用CAD内核→计算/多视图快照观察→修改→再次验证。当前项目对应实现必须把部件图、装配图、爆炸图、尺寸账本和确定性几何检查放在同一复核循环内。 +- 同行家具拆单软件的共同做法是参数化模块/板件、连接件自动生成孔位、三维爆炸检查、孔槽和工艺规则审单。图片识别输出因此必须升级为装配图谱:部件实例、接触面、断开边、运动副、连接方法、孔槽模板和证据置信度;仅有板件长宽厚列表不足以生成生产CAD。 + +## 2026-09-07 v69-v71 新增通用规则 + +- CAD复核发现高置信数量、无门/有门、抽屉排列、层板布局、侧板分段或R角差异时,只允许将多视角一致结论转换为可审计的拓扑补丁;禁止丢弃当前BOM后让模型整份自由重画。 +- 带尺寸标注的轴测/透视图仍可承担第三视角。若宽泛分类器把两张轴测图都判成尺寸图,必须再由只看像素的AI裁决哪张证明了三维深度,并把另一张保留为尺寸图,不得要求用户重复上传已有证据。 +- CAD复核输入采用带asset_id的六图拼图,并只额外发送一张高分辨率尺寸图;禁止把4张原图、2张CAD和拼图全部重复发送。 +- CAD复核单次模型请求默认硬超时180秒,格式重试由外层统一控制;禁止“内层3次×外层2次×360秒”的嵌套等待。 +- “请AI根据本轮复核自行修复”属于执行指令,不属于用户提供的结构事实;不得仅因更新了来源字段就跳过新的AI差异补丁。只有包含明确数量、尺寸、门型或有无部件的消息才可写成用户拓扑确认。 + +## 2026-09-07 v77 审计纠正与当前真实状态 + +### 本轮用户交互原则 + +用户强调提升AI自身读图理解能力,而不是将大量判断丢给用户。因此流程应为:原图多视角理解 → 独立尺寸箭头复读 → CAD/BOM几何检查 → 原图与CAD差异复核 → 有证据的自动修复(有界重试)。仍不可见且影响成本的关键条件才集中询问一次;获得预估授权的隐蔽尺寸保留估算来源,不得伪装实测。 + +### 必须纠正的历史验收错误 + +- 生产 `ai_solid_wood_cost_engine()` 缓存实例,覆盖磁盘引擎不意味着运行时已更新。2026-09-07本轮核查主进程仍为PID3902140,启动时间09:20:37;v72–v76在磁盘的版本不能当作Chat API实际执行版本。 +- 已去除读图提示中与书架样本完全相同的 `[480,554,554]`、`[300,200,200]` 和餐柜门片数值。这种提示包含答案的测试不能证明泛化。 +- 测试脚本不再自动替用户回复“柜深40cm、移门54cm、无背板”等产品事实;附件名称附加本次会话唯一标记,禁止误取历史项目。运行时版本从实际报价summary读取,不能取脚本从磁盘导入的常量。 +- 书架“宽480mm”答案键被撤销并标记待核实,不以另一个猜测替代。完整成本5%目标仍未通过。 + +### 通用代码修正(候选代码,尚不能声称线上验收完成) + +- 尺寸独立转录增加数值、单位、整体/局部/模块范围和标注两端;没有BOM表的尺寸图也可复读箭头。不接受无证据的第二次整体三元组覆盖前次数据。 +- 表格读图遗漏尺寸箭头字段时,发起更窄的AI复读,仍提供原始多视角作方向/单位参考;额外复读失败不丢掉已经成功的零件表。 +- 尺寸值与证据原子恢复;没有拓扑约束也保留明确用户尺寸或独立标注依据,不再将旧证据套在新数值上。 +- 去除“宽深刚好互换就直接忽略问题”的捷径;必须有明确轴向证据。读图同时声称某轴已标注和未标注时按冲突处理,不盲目接受。 +- AI修复只采信原始图,CAD渲染图不得给自身投票,同一asset_id不得重复计票;AI拓扑不再屏蔽之后的反证。 +- AI分段/装配坐标不再标作用户确认。几何规范化保存结构化 dimension_kind,防止把“整体深度减主体板厚”这条说明里的“厚”误当成厚度值,导致多轮规范化将20mm板厚变成480mm。 +- 当用户同轮明确了外形尺寸,尺寸表行先保留原数值,等用户外形约束生效后再做几何修正,避免中间态先按旧外形错误裁切。 + +### 实测记录:不得把成功响应当作识别正确 + +- 118项完整单测通过(213.460秒);v77专项检查扩展到11项并通过,其中两项随后追加验证原始四视图传递与附加读图失败保留有效表格。不将重叠的专项测试累加成额外全流程次数。 +- `/tmp/cost-source-probe-v77.json`:qwen-vl-plus表格模式对两款图都缺少新尺寸箭头字段,并出现不合理的板件/五金转录;不能作为合格读图。 +- `/tmp/cost-source-arrows-v77-plus.json`:缩小任务为尺寸箭头后,两款均未返回完整可解析JSON。 +- `/tmp/cost-source-arrows-v77-qwen3.json`:qwen3-vl-plus输出格式合格,但书架把模块48错误写为整体深、30写为宽且毫米/厘米错;餐柜仍有“已给高度又称缺高度”的矛盾。 +- `/tmp/cost-source-arrows-v77-max.json`:qwen-vl-max仍混淆局部方向和单位,不足以直接替换生产模型。 +- `/tmp/cost-source-arrows-v77-qwen3-multiview.json`:四张原图都送入后可读出侧图1600mm和300mm,但仍自相矛盾地输出160mm整体高和错误整体宽;不能据此宣称多图已解决。 +- 上述均为隔离目录中的直接模型探针,不是完整Chat API验收,没有修改真实用户会话、没有录入业务单据。 +- `/tmp/cost-source-arrows-v77-qwen3-thinking.json`:qwen3-vl-plus四图+高分辨率+思考4096仍把书架高/宽报160mm,失败,不能以增加思考预算代替检验。 +- `/tmp/cost-source-arrows-v77-qwen35-thinking.json`:qwen3.5-plus四图+高分辨率+思考4096读出整体高160cm、深30cm、48为下模块高度,比此前改善;仍将抽屉面37.6误作深度并把39.8直接认作整体外宽,暂不作为生产模型切换依据。 +- `/tmp/cost-source-arrows-v77-qwen35-frame.json`:增加“先以正面长边、侧边、竖边建立投影方向,再分类尺寸线”的通用步骤后,qwen3.5-plus将书架37.6正确分类为抽屉面宽X,48/55.4分类模块高度Z,160cm/30cm分类整体高深;餐柜120/40/85cm均正确。书架39.8是否属于成品最大外宽仍待独立资料核验;这只是标注子任务改善,整份BOM测试继续。 +- `/tmp/cost-full-source-analysis-v77-qwen35.json`:直接让强模型执行原有整份BOM长提示,书架首个请求180秒超时(不以加大超时继续掩盖任务过载)。转测独立“结构与数量”识别,再与尺寸证据合并的方案。 +- 同一完整读图探针的餐柜返回成功,但错误把跨越两个抽屉的1164mm承托隔板命名为单个“抽屉底板”,只再补一个544mm底板,并遗漏内部层板。因此“返回成功/structure_questions=[]”绝不等于BOM正确。 +- `/tmp/cost-structure-v77-qwen35.json`:只问结构与数量时,餐柜识别出2抽屉、2移门、2内部层板,并区分drawer_base(柜体横隔板)与两个drawer_bottom;但对背板仍与既定答案键冲突。书架能识别两只上下抽屉及完整左右后底,但侧板仅识别为上下两段而非尺寸链中的三段,仍失败。 +- 下一轮需修复的确定性代码问题:`_ensure_complete_drawer_boxes`无条件按抽屉总数分摊柜宽,没有传入横向/纵向排列;正确的上下抽屉面宽可能被程序缩窄。需要将图像结构证据的drawer_layout贯通初次规范化、抽屉补件和实例装配,不能靠后续用户纠正才恢复。 +- 上述报告已下载至仓库`outputs/product-cost-v77/`保存(最后两个报告以最终完成版本为准)。本轮没有变更生产模型配置,没有重启生产服务,没有宣称两款Chat API/CAD/成本全流程已通过。 +- 高分辨率和视觉推理模式继续作隔离对比。参考阿里云官方参数说明:[视觉理解](https://help.aliyun.com/zh/model-studio/vision)、[视觉推理](https://help.aliyun.com/zh/model-studio/visual-reasoning)。仅文档宣称更强或格式通过不能决定上线。 + +### 下一步验收条件 + +先使原图标注读数、方向、单位和范围在无答案提示下稳定一致;必要时增加标注端点/产品投影轴的像素几何验证。确认原始资料和答案键相互匹配,再在真实运行时版本上做两款Chat API闭环,冻结报价后才查公司BOM对比。服务器有用户运行时不重启,不启动无人值守的重启守候命令。 + +## 2026-09-07 v78及结构图谱算法转向 + +- 用户再次明确:不要做单品/单测试补丁,要提高未来产品的通用识别、结构与连接能力。后续不继续扩大依赖中文名字的后处理链;新架构与边界见`STRUCTURE_GRAPH_UPGRADE.md`。 +- v78候选修正抽屉排列贯通、保留真实抽面宽厚高、区分有结构用途证据的固定承托板与移动抽底、箱体跟随抽面位置。另修初读尺寸证据中的“总宽减板厚”被同时当成厚度的词匹配错误。均未部署到生产。 +- 124项完整回归在最后的抽面跟随、首次语义匹配修正之前通过,223.882秒;随后6项v78专项回归通过,11.301秒。不得混写为最终全量通过。 +- `outputs/product-cost-v77/cost-full-source-analysis-v78-qwen35.json`两款实图模型探针完成,但两款均不合格。书架278.32秒:侧板仍连续整板、部分板件基于错误旧宽300mm推算;餐柜310.61秒:抽屉中竖板/下横档被旧名称分类误当抽面并挤掉真正抽面,内部层板也缺失。不能因请求completed就标记验收通过。 +- v78探针在最后两个本地语义/定位补丁之前启动,远程临时代码未中途更换;报告不能代表这些后续修改已经通过实图验证。 +- 新增独立`structure_graph.py`:类型化参数/单位/派生算式、显式实例与总成、连接面、图谱哈希、实例生成BOM、依赖联动、重复实体/越界/接触错误。没有SKU或产品名称分支。7项测试通过,其中对100组随机尺寸及修改后依赖做验证;只是编译器基础验证,不是100款家具验收。 +- `probe_structure_graph.py`开始隔离原图→图谱→CAD→多模态复核试验,最多两轮。不调用公司BOM、不修改业务会话;试验运行结果必须单独记录,不预先宣称通过。当前只支持正交板件,完整连接孔槽与第三方Chat API仍未迁移。 +- 本轮生产检查PID3902140、active,最近只读探针各忙碌计数均为0;没有重启生产服务、没有修改线上模型配置。 +- 首轮新图谱试验结果已保存`outputs/product-cost-graph-v1/report.json`:书架两轮74.47/69.73秒,仍有8项量纲/引用错误;餐柜两轮81.88/72秒,仍有11项连接几何错误,第二轮图谱SHA256完全未变。两款都在编译门禁停下,**未生成本轮CAD预览,更未完成Chat API/报价验收**。不能宣称自我修正已有效。 +- 核查 `_request_model_json` 会完整序列化本轮body发送,未见本地按首轮内容缓存响应的机制。图谱第二轮确实携带上一图谱及完整feedback;是否远端网关/模型有效利用反馈尚未独立证明,不能把相同响应直接归咎第三方。 +- 追加通用连接连通性检查:模型不能通过删光连接关系让漂浮板件逃过检查;8项图谱测试通过,其中100组参数变换仍通过。该追加检查在远端首轮试验启动后本地完成,未混记进原报告。 +- 最新局部回归:20项抽屉相关检查通过(36.714秒);11项v77检查通过(20.142秒)。最终当前全量尚未重跑。 +- 下一步不原样重复整图生成:先增强反馈为具体算式/轴/接触面;把能构建的失败图谱渲染为明确标记的诊断草图供AI看错位(当前脚本只有编译全通过才渲染,导致几何失败时反而缺少视觉反馈),再实施局部图谱修改/约束求解。诊断模型不能交付为通过产品。独立箭头转录应输入参数证据图,避免主生成模型再次自由猜整个外形。仍不得引用公司答案矫正生成。 + +## 2026-09-07 v79 AI优先原图裁决(候选,未上线) + +- 用户要求AI承担主要图像理解,不把低置信字段逐项转给用户。新策略固定为:逐图结构读数、尺寸箭头专项读数、跨图裁决、CAD四视图反向复核和局部图谱修补;只有所有原图确实不可见且会显著改变制造/成本的残余项,才集中询问一次。 +- `view_findings`现在逐张记录完整产品可见性、抽屉/门/层板计数、抽屉排列、侧边可见度、侧板段数和归一化接缝位置。单张图声称“侧板连续”不再能直接定案;两张有效原图的段数和接缝位置需相互支持,否则进入AI局部复读,而不是立即要求用户判断。 +- 尺寸箭头专项读取会生成外部`dimension_ledger`。编译器直接比较CAD的X/Y/Z外包围与账本,阻止把侧视图300mm深度静默写成总宽。冲突或低置信箭头不进入强制账本,不会伪装成已确认尺寸。 +- 结构图谱诊断CAD新增正面、右侧、俯视、等轴四个视图供多模态AI复核;不再只给单张等轴图。第二轮仍只允许局部JSON Patch,并保存每条修改审计。 +- v2书架图谱实测未通过:第一轮14项编译问题,第二轮降为6项;AI已自动补齐层板连接并识别抽屉需有4块侧帮、4块前后帮、2块底板,但只增加了零件定义,没有增加对应物理实例,CAD仍缺抽屉箱体。更严重的是将300mm深度误作宽度。此报告保存于`outputs/product-cost-graph-v2/book-report.json`,不能上线。 +- 新增14项图谱单测通过(0.041秒),另有2项源视图裁决专项通过;这只证明门禁/裁决代码,不代表实图识别通过。v79完整回归和新实图探针仍需继续。 + +### v79诊断失败与约束求解转向 + +- `outputs/product-cost-graph-v3b/book-report.json`确认旧路线不可接受:尺寸专项复读超时导致账本为空,CAD仍为300×300×1600mm;第一轮23项问题,第二轮反而增加到30项。虽补了抽屉实例,但坐标、连接面和总成约束更差;四视图已直接暴露出“有零件但不是产品”。该版未部署。 +- 根因改判为表示层错误:不再让多模态模型直接猜整套XYZ坐标与固定连接面。同一总成内的固定/支撑连接改为根据实体AABB正面积接触自动生成;抽屉、门等有间隙的跨总成运动关系仍须显式声明。 +- 新增`anchored_axes_v1`定位求解:AI只能输出每轴的靠边/居中/对齐锚点与偏移,程序计算最终坐标。缺轴、依赖成环、越界、悬空、重复实体都阻断,不做自动挪动遮盖。 +- 候选编译器当前19项单测通过;包含100组随机尺寸依赖变换、锚点定位参数联动、依赖成环、真实接触和跨总成隔离。当前全量148项回归通过(235.761秒)。这仍只是编译器回归,新实图闭环未通过前不上线。 + +## 2026-09-07 v80 总成局部坐标与实体干涉门(候选,未上线) + +- v7隔离STEP现场证明旧锚点仍不够:上部侧板虽然属于上部模块,却从产品Z=0开始,与下部侧板重叠;部分层板按产品总宽生成并穿过侧板;抽屉后帮、底板和侧帮也存在正体积交叠。该模型不可制造,未部署。 +- 每个总成新增可参数化`bounds_mm`,实例可把X/Y/Z锚定到所属总成。总成边界必须落在产品外形内,并与成员实体并集的实际包围盒一致;上层模块不能再复制整件产品Z范围。 +- 新增三类硬阻断:部件超出所属总成、两个实体发生正体积穿插、滑动总成在非运动轴上超出导向总成。共享面接触仍允许,不能用大公差或虚大总成隐藏错误。 +- 图谱修复提示删除了`anchored_axes_v1`下修改不存在`position_mm`的错误示例;局部修复只能改总成边界、零件尺寸、锚点或偏移。 +- 当前结构图谱24项测试、全链路156项回归全部通过(237.383秒)。这些测试证明错误门禁和既有功能未回退,不代表花城实图已经通过。 +- 首次v8隔离执行因临时SSH未继承模型密钥,在尺寸证据门停止且未生成CAD;第二次携带现有服务环境后SSH连接中断,未形成阶段检查点。随后改用独立临时systemd探针保证模型请求不随SSH断线,不触碰生产服务。 + +## 2026-09-07 v83 尺寸端点裁决与 CAD-Coder 论文复核(候选,未上线) + +- 旧生产任务 `wood_20260907103319_7449b0d8` 的源尺寸图明确存在 39.8cm 整体宽、30cm 深、160cm 高;旧CAD却以 300×480×1600mm 生成,且以全高侧板代替三段模块侧板。源图文件未被修改,是旧识图/几何结果错误和“误判复核通过”。 +- 增加尺寸标注范围裁决:只允许从已转录的数值候选按稳定索引选择,必须回看箭头/引出线两端并证明跨越产品外轮廓,不允许新造数值。v79/v80/v82/v83四项尺寸专项回归全部通过。 +- 部件图谱新增 `functional_role` 和局部 `thickness_axis`,编译器根据 `axis_order` 映射实际板面,再从实体接触分类面贴面/边贴面/边贴边,禁止从中文名称猜连接。27项图谱回归全部通过。 +- CAD-Coder 论文和官方代码/权重已核对,适用边界记录在 `CAD_CODER_INTEGRATION_EVALUATION.md`。它只作为单部件几何候选器,不作为家具装配/BOM真值来源。 +- 该模型权重约 26.7GB,官方代码默认 CUDA;未将权重下载到正在承载 Chat API 的服务器,未部署,也未把“代码可执行”当成家具制造验收通过。 + +## 2026-09-07 v84 Zero-to-CAD 与 GenCAD 路线评估(候选,未上线) + +- Zero-to-CAD、MIT GenCAD 及同名 gurul/gencad 已按论文、官方仓库和关键执行代码分别核对,边界记录在 ZERO_TO_CAD_INTEGRATION_EVALUATION.md 与 GENCAD_INTEGRATION_EVALUATION.md。 +- Zero-to-CAD 适合作为单零件孔槽、圆角、倒角和异形特征候选器;它的公开数据明确是单一连通零件,不是家具装配体,不能决定 BOM、五金或连接关系。 +- MIT GenCAD 的可借鉴点是图片—CAD 对比检索和潜扩散多候选;其训练词汇仅含直线、圆弧、圆和拉伸,且作者明确报告无效、相交和不连通输出,不作为当前主模型。 +- gurul/gencad 的截面渲染适合补足孔槽、壁厚和间隙的几何复核;其任意绝对路径 Python 执行接口不具备生产隔离边界,禁止直接接入第三方 Chat API。 +- 推荐顺序固定为:结构图谱权威层 → Zero-to-CAD 单件候选 → 受限 FreeCAD/OCCT 截面复核 → GenCAD 风格检索/多候选排序。没有一个开源项目能替代装配约束和工艺门禁。 +- 本轮只做源码/论文审计与方案归档,没有部署模型、没有重启生产服务,也没有触碰用户任务。 + +## 2026-09-07 v85 接触面到真实孔槽的制造层(候选,未上线) + +- 新增通用`manufacturing_oracle.py`。它只读取结构图谱编译器给出的物理实例、材料族、功能角色和真实AABB接触;中文显示名称变化不会改变连接方法或数量。当前基础策略覆盖人造板确认螺钉、层板托、背板/抽屉底板捕获槽、实木木螺钉、木/金属螺栓和金属焊缝,无法可靠处理的边贴边或不相容榫卯直接阻断。 +- 每个孔、槽、焊缝和连接件都有稳定的`manufacturing_connection_id`;连接件按唯一`hardware_instance_id`计数,禁止同一孔位重复加工和重复计费。孔圆周、孔深、加工面中心、槽体边界和材料兼容均有确定性检查。 +- 捕获槽不再只有“承载板切槽”这一半。制造层给嵌入板同步增加逐边入槽余量,输出`manufacturing_geometry_by_instance`和使用成品下料尺寸的`manufacturing_bom`;CAD用调整后的板件伸入槽内,并以实际布尔结果检查干涉。 +- 通用CAD构建链在单体有效性之后新增`inspect interfere --format json`。第一次服务器隔离测试发现运行时不支持`--json`并会导致所有构建失败,已改成服务器实际支持的`--format json`;这一失败尝试没有计为通过。 +- 本地完整回归172项通过(253.707秒);制造/图谱/CAD专项在捕获槽修正和证据文件语义角色后39项通过。技能结构校验通过,语法检查通过。 +- 服务器只读检查显示Chat API服务active且活动任务、等待任务和持久队列均为0。随后仅在`/tmp/dalongxia-cad-v85-eI8HeF`隔离验证,没有覆盖生产文件或重启服务。 +- 确认螺钉样例真实结果:3实体、12孔、6连接件、STEP 101927字节;切孔前后面数18→36,总体积21340800→21335663.496mm³,差值与孔模板切削体积一致;B-rep检查3对实体0冲突。 +- 捕获槽样例真实结果:左右各6mm入槽,板件下料尺寸764×400×18→776×400×18mm;3实体、26面、总体积21338400mm³,0.5mm槽宽余量对应净减2400mm³,3对实体0冲突。当前包装模型11节点、55对候选也实测0冲突,新增装配门没有误伤该模型。 +- 尚未完成:供应商专用确认螺钉/三合一/铰链/滑轨模板、运动扫掠碰撞、正交截面自动判读、真实原图到图谱首次识别、冻结后公司BOM成本误差≤5%盲测。因此v85仍为候选,不能称生产级CAD或目标完成。 + +## 2026-09-07 v87 GenCAD审计与生成—验证—修复实测(候选,未上线) + +- MIT GenCAD 的官方推理代码逐张处理图片,未调用仓库中定义的多视图编码器;其草图词汇限于直线、圆弧、圆与拉伸,论文也明确保留无效、相交和不连通输出。它可用于图片—CAD检索和多候选排序,不能直接产生家具装配/BOM真值。 +- 同名 `gurul/gencad` 的 FreeCAD 代理循环可借鉴截面渲染、执行后检查和局部修复,但其 MCP 可执行任意绝对路径 Python,不能直接接入第三方 Chat API。两仓库许可边界和审计提交已记录在 `GENCAD_INTEGRATION_EVALUATION.md`。 +- 候选结构编译器新增自身总成 ID 锚点别名、跨总成误用阻断、运动副实例端点的可执行修复映射;探针新增结构化 B-rep 冲突反馈和非法补丁反馈。专项 44 项回归通过;最终候选完整回归 180 项通过(253.928 秒)。 +- 服务器隔离样例在原始四图上正确建立数量合同与 1200×400×800mm 尺寸账本,但三轮仍未收敛:编译问题 60→55→59,B-rep 冲突 3→6→5,最终状态 `graph_and_cad_kernel_not_passed`。失败报告位于服务器临时目录 `/tmp/dalongxia-cad-v85-eI8HeF/probe-sideboard4/sideboard/report.json`。 +- 本轮确认的新根因是轴语义仍由模型以 `axis_order` 自由表达,局部修复可能把门板宽高厚重新映射到错误产品轴;同时修复提示不足以阻止对多个实例盲加统一偏移。下一步应把面板厚度轴和功能角色编译成受限放置模板,再让模型只选择开口、靠边关系和间隙参数。 +- 未修改生产文件、未重启 Chat API、未改变用户任务;真实多产品盲测、公司 BOM 事后对比和总成本误差≤5%仍未完成。 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/ZERO_TO_CAD_INTEGRATION_EVALUATION.md b/server_patch/ai_3d_model/ai-solid-wood-cost/ZERO_TO_CAD_INTEGRATION_EVALUATION.md new file mode 100644 index 0000000..df58053 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/ZERO_TO_CAD_INTEGRATION_EVALUATION.md @@ -0,0 +1,31 @@ +# Zero-to-CAD 接入评估 + +## 结论 + +Zero-to-CAD 比 CAD-Coder 和 GenCAD 更适合承担单个零件的几何候选生成,尤其是孔、槽、沉头孔、圆角、倒角、壳体、阵列等局部特征;它仍不能直接作为家具装配、BOM 数量、连接方式或生产工艺的真值来源。 + +## 已核对事实 + +- Zero-To-CAD-1m 含 999,633 条可执行 CadQuery 程序;100K 是筛选后的多样化子集。 +- 每条样本提供 8 个 256×256 渲染视角、CadQuery 代码、操作列表、STL 和 STEP。 +- 操作覆盖草图、拉伸、切除、旋转、放样、扫掠、圆角、倒角、抽壳、偏移、布尔、阵列、镜像、通孔、盲孔、沉头孔、螺纹和加强筋。 +- 数据生成采用“模型写代码—执行—校验—修复”的代理循环;但校验只保证可执行、拓扑有效、单一连通实体、最少面数、正体积及导出成功,不等于完整可制造性检查。 +- 官方提示明确把每条样本限定为一个自包含零件,不是装配体。 +- 2B 模型要求 8 张干净 CAD 渲染图;官方说明真实照片、噪声背景和复杂多部件装配仍可能失败。 +- 论文在合成测试集上执行成功率 82.1%、平均 IoU 0.747;在 ABC 人工设计分布外数据上降至 61.0% 和 0.377。IoU 使用归一化和旋转最佳匹配,不证明绝对尺寸、朝向或制造关系正确。 + +## 推荐接法 + +1. 首先试验 100K 数据和 2B 模型,不直接把 1M 全量导入生产。 +2. 现有多模态模块先完成产品总成、部件实例、尺寸证据和连接图谱。 +3. 对需要孔槽、异形、圆角等特征的单个零件,程序生成 8 个规范视角,再让 Zero-to-CAD 输出多个局部几何候选。 +4. 每个候选必须通过绝对尺寸、实体有效性、孔槽数量/位置、部件边界、连接面、装配干涉和材料工艺门禁。 +5. 候选失败时只修复该零件,不允许模型重新解释整个产品或重写完整 BOM。 + +## 禁止事项 + +- 不以归一化 IoU 代替毫米尺寸验收。 +- 不把单零件模型用于决定抽屉数量、门型、五金或总成关系。 +- 不在当前承载 Chat API 的生产进程内直接加载模型权重。 +- 不把能够导出 STEP 当作工厂可生产证明。 + diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/manufacturing_oracle.py b/server_patch/ai_3d_model/ai-solid-wood-cost/manufacturing_oracle.py new file mode 100644 index 0000000..2c32693 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/manufacturing_oracle.py @@ -0,0 +1,445 @@ +"""Geometry-driven manufacturing feature planner for compiled assemblies. + +The image model identifies physical parts and assembly membership. This +module turns verified geometric contacts into holes, grooves or weld seams. +It never infers a joint from a Chinese display name and never silently clamps +an invalid feature into the part. The output remains a manufacturing draft; +an independent B-rep/section inspection is still required before release. +""" +from __future__ import annotations + +import math +from collections import Counter + + +SCHEMA_VERSION = "dalongxia.manufacturing-feature-plan/1" + +PANEL_ROLES = { + "top", "bottom", "side", "shelf", "door", "back", "drawer_front", + "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", + "drawer_support", "divider", +} +CAPTURED_ROLES = {"back", "drawer_bottom"} +ALLOWED_MATERIALS = {"engineered_panel", "solid_wood", "metal", "plastic", "glass", "other"} + +DEFAULT_POLICY = { + "default_material_family": "engineered_panel", + "assembly_mode": "knock_down", + "engineered_panel_joint": "confirmat", + "solid_wood_joint": "wood_screw", + "mixed_joint": "bolt", + "metal_joint": "weld", + "shelf_joint": "shelf_pin", + "captured_panel_joint": "captured_groove", + "max_connector_spacing_mm": 240.0, + "connector_edge_offset_mm": 45.0, + "groove_clearance_mm": 0.5, +} + + +def _finite_positive(value, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: + raise ValueError(f"{label}必须为有限正数") + return float(value) + + +def _axis_index(face: str) -> int: + if not isinstance(face, str) or len(face) != 2 or face[0] not in "xyz" or face[1] not in "+-": + raise ValueError("连接面必须为x-/x+/y-/y+/z-/z+") + return "xyz".index(face[0]) + + +def _face_plane(instance: dict, face: str) -> float: + axis = _axis_index(face) + return float(instance["position_mm"][axis]) + (float(instance["size_mm"][axis]) if face[1] == "+" else 0.0) + + +def _inside_center(instance: dict, face: str, depth: float) -> list[float]: + axis = _axis_index(face) + center = [float(instance["position_mm"][i]) + float(instance["size_mm"][i]) / 2.0 for i in range(3)] + plane = _face_plane(instance, face) + center[axis] = plane - depth / 2.0 if face[1] == "+" else plane + depth / 2.0 + return center + + +def _local_center(instance: dict, world_center: list[float]) -> dict: + axis_order = list(instance.get("axis_order") or [0, 1, 2]) + local = [0.0, 0.0, 0.0] + for product_axis in range(3): + local_axis = axis_order[product_axis] + local[local_axis] = float(world_center[product_axis]) - float(instance["position_mm"][product_axis]) + return {"x": round(local[0], 4), "y": round(local[1], 4), "z": round(local[2], 4)} + + +def _world_dict(values: list[float]) -> dict: + return {axis: round(float(values[index]), 4) for index, axis in enumerate("xyz")} + + +def _overlap_ranges(a: dict, b: dict, normal_axis: int) -> dict[int, tuple[float, float]]: + ranges = {} + for axis in range(3): + if axis == normal_axis: + continue + low = max(float(a["position_mm"][axis]), float(b["position_mm"][axis])) + high = min( + float(a["position_mm"][axis]) + float(a["size_mm"][axis]), + float(b["position_mm"][axis]) + float(b["size_mm"][axis]), + ) + ranges[axis] = (low, high) + return ranges + + +def _line_points( + a: dict, + b: dict, + normal_axis: int, + policy: dict, + *, + face_a: str, + face_b: str, + fixed_count: int | None = None, +) -> list[list[float]]: + ranges = _overlap_ranges(a, b, normal_axis) + if any(high - low <= 0.01 for low, high in ranges.values()): + return [] + run_axis = max(ranges, key=lambda axis: ranges[axis][1] - ranges[axis][0]) + cross_axis = next(axis for axis in ranges if axis != run_axis) + low, high = ranges[run_axis] + length = high - low + edge = min(float(policy["connector_edge_offset_mm"]), max(0.0, length / 3.0)) + usable = max(0.0, length - 2 * edge) + if fixed_count is not None: + count = max(1, int(fixed_count)) + else: + spacing = _finite_positive(policy["max_connector_spacing_mm"], "最大连接件间距") + count = max(2, int(math.ceil(usable / spacing)) + 1) + positions = [low + length / 2.0] if count == 1 else [low + edge + usable * i / (count - 1) for i in range(count)] + plane = (_face_plane(a, face_a) + _face_plane(b, face_b)) / 2.0 + points = [] + for coordinate in positions: + point = [0.0, 0.0, 0.0] + point[normal_axis] = plane + point[run_axis] = coordinate + point[cross_axis] = sum(ranges[cross_axis]) / 2.0 + points.append(point) + return points + + +def _validate_hole(instance: dict, feature: dict) -> list[str]: + messages = [] + axis = "xyz".index(feature["axis"]) + center = [feature["world_center_mm"][name] for name in "xyz"] + radius = float(feature["diameter_mm"]) / 2.0 + depth = float(feature["depth_mm"]) + for tangent in range(3): + low = float(instance["position_mm"][tangent]) + high = low + float(instance["size_mm"][tangent]) + margin = 0.0 if tangent == axis else radius + if center[tangent] - margin < low - 0.01 or center[tangent] + margin > high + 0.01: + messages.append(f"孔圆周超出零件{'xyz'[tangent].upper()}轴边界") + if depth > float(instance["size_mm"][axis]) + 0.01: + messages.append("孔深超过零件厚度/轴向尺寸") + expected = _inside_center(instance, feature["face"], depth) + if abs(center[axis] - expected[axis]) > 0.05: + messages.append("孔中心与声明加工面、孔深不一致") + return messages + + +def _method_for(contact: dict, instances: dict, policy: dict) -> str: + a, b = instances[contact["a"]], instances[contact["b"]] + roles = {a.get("functional_role"), b.get("functional_role")} + materials = {a.get("material_family"), b.get("material_family")} + if roles & CAPTURED_ROLES: + return str(policy["captured_panel_joint"]) + if "shelf" in roles: + return str(policy["shelf_joint"]) + if materials == {"metal"}: + return str(policy["metal_joint"]) + if "metal" in materials: + return str(policy["mixed_joint"]) + if "engineered_panel" in materials: + return str(policy["engineered_panel_joint"]) + if materials == {"solid_wood"}: + return str(policy["solid_wood_joint"]) + return "unresolved" + + +def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) -> dict: + """Return a deterministic manufacturing draft from a compiled graph.""" + active_policy = {**DEFAULT_POLICY, **dict(policy or {})} + issues: list[dict] = [] + + def issue(code: str, detail: str, **context) -> None: + issues.append({"code": code, "detail": detail, "severity": "blocking", **context}) + + if not isinstance(compiled, dict) or not compiled.get("ok"): + return { + "schema_version": SCHEMA_VERSION, + "ok": False, + "release_status": "blocked_by_assembly_graph", + "issues": [{"code": "assembly_graph_not_valid", "detail": "装配图谱未通过,禁止生成孔槽", "severity": "blocking"}], + "features_by_instance": {}, "connection_plans": [], "hardware_lines": [], + } + default_material = str(active_policy.get("default_material_family") or "") + if default_material not in ALLOWED_MATERIALS: + raise ValueError("默认材料族无效") + instances = {str(row["id"]): dict(row) for row in compiled.get("instances") or []} + for row in instances.values(): + material = str(row.get("material_family") or default_material) + if material not in ALLOWED_MATERIALS: + issue("material_family", "实例材料族无效", instance_id=row["id"], material_family=material) + row["material_family"] = material + features = {key: {"holes": [], "grooves": [], "welds": []} for key in instances} + # The assembly graph describes visible/net contact geometry. A captured + # back or drawer bottom must extend into the receiver after its groove is + # cut. Keep this manufacturing geometry separate and auditable instead + # of either leaving the insert at the groove mouth or hiding a positive + # AABB overlap in the source graph. + manufacturing_geometry = { + key: { + "instance_id": key, + "part_id": row.get("part_id"), + "axis_order": list(row.get("axis_order") or [0, 1, 2]), + "position_mm": [float(value) for value in row["position_mm"]], + "size_mm": [float(value) for value in row["size_mm"]], + "capture_allowances": [], + } + for key, row in instances.items() + } + connection_plans = [] + hardware = Counter() + hardware_names = { + "confirmat": ("确认螺钉", "7×50mm"), + "wood_screw": ("木螺钉", "4×40mm"), + "shelf_pin": ("层板托", "5mm"), + "bolt": ("连接螺栓", "M6"), + } + sequence = 0 + + def add_hole(instance: dict, *, point: list[float], face: str, diameter: float, depth: float, + connection_id: str, hardware_id: str, hole_type: str) -> None: + nonlocal sequence + sequence += 1 + axis_index = _axis_index(face) + center = _inside_center(instance, face, depth) + for tangent in range(3): + if tangent != axis_index: + center[tangent] = point[tangent] + feature = { + "feature_id": f"HOLE-{sequence:05d}", "kind": "hole", + "manufacturing_connection_id": connection_id, "hardware_instance_id": hardware_id, + "instance_id": instance["id"], "axis": "xyz"[axis_index], "face": face, + "world_center_mm": _world_dict(center), "local_center_mm": _local_center(instance, center), + "diameter_mm": round(float(diameter), 3), "depth_mm": round(float(depth), 3), + "hole_type": hole_type, "source": "verified_geometric_contact", + } + errors = _validate_hole(instance, feature) + feature["validation_status"] = "blocked" if errors else "valid" + if errors: + issue("hole_geometry", ";".join(errors), instance_id=instance["id"], feature_id=feature["feature_id"]) + features[instance["id"]]["holes"].append(feature) + + for contact in compiled.get("derived_contacts") or []: + a, b = instances.get(str(contact.get("a"))), instances.get(str(contact.get("b"))) + if not a or not b: + issue("contact_target", "制造接触引用了不存在的实例", contact_id=contact.get("id")) + continue + try: + normal_axis = _axis_index(str(contact.get("face_a") or "")) + except ValueError as exc: + issue("contact_face", str(exc), contact_id=contact.get("id")) + continue + method = _method_for(contact, instances, active_policy) + connection_id = str(contact.get("id") or f"contact:{a['id']}:{b['id']}") + plan = {"connection_id": connection_id, "a": a["id"], "b": b["id"], "method": method, + "interface_type": contact.get("interface_type"), "feature_ids": [], "hardware_instance_ids": []} + if method == "mortise_tenon" and "engineered_panel" in {a["material_family"], b["material_family"]}: + issue("material_process_incompatible", "人造板接触不得使用传统承力榫卯", contact_id=connection_id) + connection_plans.append(plan) + continue + if method == "unresolved" or contact.get("interface_type") == "edge_to_edge" and method not in {"weld"}: + issue("joint_method_unresolved", "该接触界面没有可靠的量产连接方式", contact_id=connection_id, + interface_type=contact.get("interface_type"), material_families=[a["material_family"], b["material_family"]]) + connection_plans.append(plan) + continue + if method == "captured_groove": + insert = a if a.get("functional_role") in CAPTURED_ROLES else b if b.get("functional_role") in CAPTURED_ROLES else None + receiver_id = contact.get("broad_face_instance_id") + receiver = instances.get(str(receiver_id)) if receiver_id else None + if not insert or not receiver or insert["id"] == receiver["id"]: + issue("groove_receiver_unresolved", "嵌入薄板接触无法确定开槽承载件", contact_id=connection_id) + connection_plans.append(plan) + continue + if ( + contact.get("interface_type") != "edge_to_face" + or str(contact.get("edge_instance_id") or "") != insert["id"] + ): + issue( + "captured_interface_incompatible", + "嵌入薄板必须以板边进入承载件宽面;宽面对宽面接触不能自动生成捕获槽", + contact_id=connection_id, + insert_instance_id=insert["id"], + interface_type=contact.get("interface_type"), + ) + connection_plans.append(plan) + continue + insert_thickness_axis = insert.get("thickness_axis") + if insert_thickness_axis not in "xyz": + issue("insert_thickness_axis", "嵌入板未声明产品坐标厚度轴", contact_id=connection_id) + connection_plans.append(plan) + continue + insert_thickness = float(insert["size_mm"]["xyz".index(insert_thickness_axis)]) + clearance = float(active_policy["groove_clearance_mm"]) + receiver_face = contact["face_a"] if contact["a"] == receiver["id"] else contact["face_b"] + depth = min(6.0, float(receiver["size_mm"][normal_axis]) * 0.4) + ranges = _overlap_ranges(a, b, normal_axis) + run_axis = max(ranges, key=lambda axis: ranges[axis][1] - ranges[axis][0]) + cross_axis = next(axis for axis in ranges if axis != run_axis) + world_size = [depth if axis == normal_axis else 0.0 for axis in range(3)] + world_size[run_axis] = ranges[run_axis][1] - ranges[run_axis][0] + world_size[cross_axis] = insert_thickness + clearance + center = _inside_center(receiver, receiver_face, depth) + center[run_axis] = sum(ranges[run_axis]) / 2.0 + center[cross_axis] = float(insert["position_mm"][cross_axis]) + float(insert["size_mm"][cross_axis]) / 2.0 + sequence += 1 + groove = { + "feature_id": f"GROOVE-{sequence:05d}", "kind": "groove", + "manufacturing_connection_id": connection_id, "instance_id": receiver["id"], + "face": receiver_face, "world_center_mm": _world_dict(center), + "world_size_mm": _world_dict(world_size), "insert_instance_id": insert["id"], + "insert_thickness_mm": round(insert_thickness, 3), "clearance_mm": round(clearance, 3), + "source": "verified_geometric_contact", "validation_status": "valid", + } + receiver_low = receiver["position_mm"] + for axis in range(3): + if center[axis] - world_size[axis] / 2 < receiver_low[axis] - 0.01 or center[axis] + world_size[axis] / 2 > receiver_low[axis] + receiver["size_mm"][axis] + 0.01: + groove["validation_status"] = "blocked" + issue("groove_geometry", "槽体超出承载零件边界", contact_id=connection_id, instance_id=receiver["id"]) + break + features[receiver["id"]]["grooves"].append(groove) + plan["feature_ids"].append(groove["feature_id"]) + insert_face = contact["face_a"] if contact["a"] == insert["id"] else contact["face_b"] + insert_axis = _axis_index(insert_face) + adjusted = manufacturing_geometry[insert["id"]] + if insert_face[1] == "-": + adjusted["position_mm"][insert_axis] -= depth + adjusted["size_mm"][insert_axis] += depth + allowance = { + "connection_id": connection_id, + "axis": "xyz"[insert_axis], + "face": insert_face, + "extension_mm": round(depth, 4), + "receiver_instance_id": receiver["id"], + "groove_feature_id": groove["feature_id"], + } + adjusted["capture_allowances"].append(allowance) + plan["insert_allowance"] = allowance + elif method == "weld": + ranges = _overlap_ranges(a, b, normal_axis) + length = max(high - low for low, high in ranges.values()) + sequence += 1 + weld = {"feature_id": f"WELD-{sequence:05d}", "kind": "weld_seam", + "manufacturing_connection_id": connection_id, "length_mm": round(length, 3), + "process": "fillet_weld", "source": "verified_metal_contact"} + features[a["id"]]["welds"].append(weld) + plan["feature_ids"].append(weld["feature_id"]) + else: + if contact.get("interface_type") != "edge_to_face": + issue("connector_interface", "螺钉/层板托/螺栓模板只允许边对面接触", contact_id=connection_id, + interface_type=contact.get("interface_type")) + connection_plans.append(plan) + continue + broad = instances[str(contact["broad_face_instance_id"])] + edge = instances[str(contact["edge_instance_id"])] + broad_face = contact["face_a"] if contact["a"] == broad["id"] else contact["face_b"] + edge_face = contact["face_a"] if contact["a"] == edge["id"] else contact["face_b"] + fixed_count = 2 if method == "shelf_pin" else None + points = _line_points( + a, b, normal_axis, active_policy, + face_a=contact["face_a"], face_b=contact["face_b"], fixed_count=fixed_count, + ) + if not points: + issue("connector_span", "接触面没有足够的正面积布置连接件", contact_id=connection_id) + connection_plans.append(plan) + continue + diameter = 5.0 if method == "shelf_pin" else 6.0 if method == "bolt" else 5.0 if method == "confirmat" else 3.0 + for ordinal, point in enumerate(points, 1): + hardware_id = f"HW:{connection_id}:{ordinal}" + broad_depth = float(broad["size_mm"][normal_axis]) + add_hole(broad, point=point, face=broad_face, diameter=diameter, depth=broad_depth, + connection_id=connection_id, hardware_id=hardware_id, hole_type="through") + if method != "shelf_pin": + edge_depth = min(40.0 if method == "confirmat" else 30.0, float(edge["size_mm"][normal_axis]) - 1.0) + if edge_depth <= 0: + issue("connector_depth", "边部件轴向尺寸不足以容纳盲孔", contact_id=connection_id, instance_id=edge["id"]) + else: + add_hole(edge, point=point, face=edge_face, diameter=max(2.5, diameter - 1.0), depth=edge_depth, + connection_id=connection_id, hardware_id=hardware_id, hole_type="blind") + hardware[method] += 1 + plan["hardware_instance_ids"].append(hardware_id) + plan["feature_ids"] = [ + row["feature_id"] for instance_features in features.values() + for row in instance_features["holes"] + instance_features["grooves"] + instance_features["welds"] + if row.get("manufacturing_connection_id") == connection_id + ] + connection_plans.append(plan) + + # A duplicated operation would be machined and charged twice. Detect it + # by geometry and instance, independent of feature names. + for instance_id, rows in features.items(): + seen = {} + for feature in rows["holes"]: + signature = (feature["axis"], tuple(feature["world_center_mm"][a] for a in "xyz"), feature["diameter_mm"], feature["depth_mm"]) + if signature in seen: + issue("duplicate_feature", "同一孔位被重复生成,禁止重复加工和计费", instance_id=instance_id, + feature_ids=[seen[signature], feature["feature_id"]]) + seen[signature] = feature["feature_id"] + + hardware_lines = [ + {"hardware_code": method, "name": hardware_names[method][0], "specification": hardware_names[method][1], + "quantity": quantity, "unit": "个", "quantity_source": "unique_hardware_instance_id"} + for method, quantity in sorted(hardware.items()) if quantity and method in hardware_names + ] + hole_count = sum(len(row["holes"]) for row in features.values()) + groove_count = sum(len(row["grooves"]) for row in features.values()) + weld_length = sum(float(feature["length_mm"]) for row in features.values() for feature in row["welds"]) + bom_names = {str(row.get("part_id")): str(row.get("name") or row.get("part_id")) for row in compiled.get("bom") or []} + manufacturing_bom_groups: dict[tuple, dict] = {} + for instance_id, geometry in manufacturing_geometry.items(): + axis_order = geometry["axis_order"] + local_size = [0.0, 0.0, 0.0] + for product_axis, local_axis in enumerate(axis_order): + local_size[local_axis] = float(geometry["size_mm"][product_axis]) + local_size = [round(value, 4) for value in local_size] + signature = (str(geometry.get("part_id") or ""), tuple(local_size)) + if signature not in manufacturing_bom_groups: + part_id = signature[0] + manufacturing_bom_groups[signature] = { + "part_id": part_id, + "name": bom_names.get(part_id, part_id), + "manufacturing_size_mm": local_size, + "quantity": 0, + "instance_ids": [], + "size_source": "compiled_net_geometry_plus_verified_capture_allowances", + } + manufacturing_bom_groups[signature]["quantity"] += 1 + manufacturing_bom_groups[signature]["instance_ids"].append(instance_id) + return { + "schema_version": SCHEMA_VERSION, + "ok": not issues, + "release_status": "manufacturing_draft" if not issues else "blocked", + "policy": active_policy, + "issues": issues, + "features_by_instance": features, + "manufacturing_geometry_by_instance": manufacturing_geometry, + "manufacturing_bom": list(manufacturing_bom_groups.values()), + "connection_plans": connection_plans, + "hardware_lines": hardware_lines, + "cost_drivers": { + "hole_operations": hole_count, + "groove_operations": groove_count, + "weld_length_m": round(weld_length / 1000.0, 4), + "hardware_instances": sum(hardware.values()), + }, + "validation_required": ["brep_boolean", "section_oracle", "assembly_interference", "visual_source_comparison"], + } diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py new file mode 100644 index 0000000..f13ead0 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/probe_structure_graph.py @@ -0,0 +1,501 @@ +"""Isolated original-images → parametric graph → CAD → visual feedback trial. + +No company BOM enters inference. No business sessions or production services +are mutated. A successful compile/render is NOT visual or pricing acceptance. +""" +from __future__ import annotations + +import argparse +import base64 +import importlib.util +import json +import os +import pathlib +import re +import time + +from structure_graph import ( + GraphError, SCHEMA_VERSION, apply_graph_patch, build_dimension_ledger, + build_source_topology_contract, compile_graph, +) +from manufacturing_oracle import plan_manufacturing_features + + +ROLE_FILENAME_ALIASES = { + "front": ("front",), + "side": ("side",), + "third": ("third", "open", "three-quarter", "perspective"), + "dimension": ("dimension", "dimensions", "dim", "drawing"), +} + + +def source_for_role(asset_dir: pathlib.Path, case: str, role: str) -> pathlib.Path | None: + """Resolve test evidence by semantic role, not one fragile filename.""" + aliases = ROLE_FILENAME_ALIASES[role] + allowed = {".jpg", ".jpeg", ".png", ".webp"} + candidates = [] + for alias_index, alias in enumerate(aliases): + for path in asset_dir.glob(f"{case}-{alias}.*"): + if path.suffix.lower() in allowed: + candidates.append((alias_index, path.name, path)) + return min(candidates)[2] if candidates else None + + +def _json_object_from_error(error: Exception | str) -> dict | None: + """Extract the first structured CAD-kernel payload from an exception.""" + text = str(error or "").strip() + decoder = json.JSONDecoder() + for index, character in enumerate(text): + if character != "{": + continue + try: + value, _ = decoder.raw_decode(text[index:]) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + return value + return None + + +def structured_kernel_feedback(error: Exception | str) -> list[dict]: + """Turn B-rep failures into stable-instance repair instructions.""" + payload = _json_object_from_error(error) + feedback = [] + if payload: + for clash in payload.get("clashes") or []: + if not isinstance(clash, dict): + continue + first = str((clash.get("a") or {}).get("name") or "").strip() + second = str((clash.get("b") or {}).get("name") or "").strip() + bounds = clash.get("bounds") or {} + minimum, maximum = bounds.get("min") or [], bounds.get("max") or [] + overlap = { + axis: round(float(maximum[position]) - float(minimum[position]), 4) + for position, axis in enumerate("xyz") + if position < len(minimum) and position < len(maximum) + } + feedback.append({ + "code": "brep_interference", + "severity": "blocking", + "instance_ids": [value for value in (first, second) if value], + "volume_mm3": clash.get("volume"), + "overlap_size_mm": overlap, + "bounds_mm": bounds, + "correction": ( + "回看原图后修改这两个实例的零件尺寸、所属总成或placement整轴约束," + "使实体没有正体积重叠;不得删除原图可见零件或缩小到错误尺寸。" + ), + "legal_patch_roots": [ + f"/instances/{instance_id}/placement" for instance_id in (first, second) if instance_id + ], + }) + for detail in payload.get("errors") or []: + feedback.append({ + "code": "cad_kernel_validation_error", "severity": "blocking", + "detail": str(detail), + }) + if feedback: + return feedback + return [{ + "code": "cad_kernel_error", "severity": "blocking", + "detail": str(error or "CAD几何内核未通过")[-1800:], + "correction": "只修改导致内核失败的图谱参数、零件几何或定位约束,然后重新构建。", + }] + + +def structured_patch_feedback(graph: dict, patches, error: Exception | str) -> list[dict]: + """Explain a rejected JSON Patch without silently changing its semantics.""" + issues = [] + instances = list(graph.get("instances") or []) if isinstance(graph, dict) else [] + for patch in patches if isinstance(patches, list) else []: + path = str(patch.get("path") or "") if isinstance(patch, dict) else "" + match = re.fullmatch(r"/instances/([^/]+)/placement/([xyz])/(value_mm|offset_mm)", path) + if not match: + continue + token, axis, leaf = match.groups() + instance = None + if token.isdigit() and int(token) < len(instances): + instance = instances[int(token)] + else: + instance = next((row for row in instances if str(row.get("id") or "") == token), None) + if not isinstance(instance, dict): + continue + stable_id = str(instance.get("id") or token) + current = ((instance.get("placement") or {}).get(axis) or {}) + legal = [{ + "op": "replace", "path": f"/instances/{stable_id}/placement/{axis}", + "value_shape": {"mode": "absolute", "value_mm": "<毫米值>"}, + }] + if current.get("mode") == "align": + legal.append({ + "op": "add" if "offset_mm" not in current else "replace", + "path": f"/instances/{stable_id}/placement/{axis}/offset_mm", + "value_shape": "<毫米偏移值>", + }) + issues.append({ + "code": "patch_application_error", "severity": "blocking", + "attempted_path": path, "instance_id": stable_id, "axis": axis, + "current_constraint": current, "legal_repair_examples": legal, + "detail": str(error), + "correction": ( + f"当前{axis.upper()}轴没有可replace的{leaf}字段。使用稳定实例ID并替换整个轴约束;" + "如果当前是align且只需平移,则对offset_mm使用add/replace。" + ), + }) + return issues or [{ + "code": "patch_application_error", "severity": "blocking", + "detail": str(error), "attempted_patches": patches, + "correction": "保留上一版图谱,根据现有字段和稳定ID重新生成合法局部修改。", + }] + + +GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具装配图谱,不输出报价。 +先理解产品正面、侧面和尺寸线方向,再分清每个物理板件及其所属总成。 +不能把抽屉整体算成板件,固定横隔板不属于抽屉箱体;每个物理实例有独立ID。 +侧板是否断开需要局部接缝证据,颜色变化不等于断开,层板边缘不等于侧板接缝。 +多张图存在矛盾则在uncertainties中记录;未看见不能当作没有。 +严格JSON结构: + {"schema_version":"furniture-assembly-graph/1", + "placement_solver":"anchored_axes_v1", + "contact_derivation":"aabb_within_assembly_v1", + "observations":[{"id":"e1","source_type":"image","image_id":"image1","confidence":0.0,"text":"原图观察,不是经验","region":[0,0,1,1]}], + "parameters":{"parameter_name":{"kind":"length/count/scalar","origin":"observed/derived/estimated","value":0,"unit":"mm/cm/m","evidence_ids":["e1"],"confidence":0.0}}, + "envelope_mm":["width","depth","height"], + "assemblies":[{"id":"lower_module","role":"carcass","bounds_mm":{"x":[0,"width"],"y":[0,"depth"],"z":[0,"lower_height"]},"evidence_ids":["e1"]},{"id":"drawer_1","role":"drawer","bounds_mm":{"x":["side_thickness","width-side_thickness"],"y":[0,"drawer_depth"],"z":["drawer_z_min","drawer_z_max"]},"evidence_ids":["e1"]}], + "parts":[{"id":"part_id","name":"中文部件名","shape":"box","functional_role":"side","thickness_axis":"x","size_mm":["参数或算式","参数或算式","参数或算式"],"observed_quantity":1,"evidence_ids":["e1"]}], + "instances":[{"id":"物理实例ID","part_id":"part_id","assembly_id":"明确所属总成ID","axis_order":[0,1,2],"placement":{"x":{"mode":"align","self_anchor":"min","target":"envelope","target_anchor":"min","offset_mm":0},"y":{"mode":"absolute","value_mm":0},"z":{"mode":"align","self_anchor":"max","target":"另一实例ID","target_anchor":"min","offset_mm":0}}}], + "connections":[], + "assembly_connections":[{"id":"drawer_slide","kind":"sliding","a":"drawer_1","b":"carcass","axis":"y","travel_limits_mm":[0,250],"evidence_ids":["e1"]}], + "uncertainties":[]} +示例字段名仅是格式,不是产品答案。不要照抄示例观察。 +参数:observed仅用于图上明确可读的标注,必须引用实际图片证据;照片比例、隐蔽板厚等用estimated且confidence<=0.55。 +derived参数不写value/unit,改写expression为参数名和加减乘除的算式,如width-2*panel_thickness。 +先定义变量,再引用;数字是无量纲系数,不要把mm和件数直接相加。零件size_mm/position_mm中数字字面量单位固定mm。 +size_mm为零件本地尺寸,axis_order明确映射到产品X宽/Y深/Z高。固定写placement_solver=anchored_axes_v1,禁止输出position_mm或心算XYZ坐标。 +每个part必须声明functional_role,只允许top/bottom/side/shelf/door/back/drawer_front/drawer_left/drawer_right/drawer_back/drawer_bottom/drawer_support/divider/leg/rail/stile/frame_member/hardware/other。 +所有板件还必须声明thickness_axis=x/y/z,它是size_mm本地坐标中最薄的一轴;不能根据中文名称猜方向。非板件可写null。 +例如size_mm=[t,depth,height]的侧板写thickness_axis=x;size_mm=[inner_width,depth,t]的层板写thickness_axis=z。axis_order再把这个本地厚度轴映射到产品坐标。 +每个实例的placement必须有x/y/z三轴。每轴只允许absolute(value_mm),或align(self_anchor,target,target_anchor,offset_mm)。 +anchor只能是min/center/max;target只能是envelope、字面量assembly、当前实例自己的assembly_id,或先定义的实例ID。当前assembly_id与assembly等价,不能用其他总成ID做跨总成定位。每个部件优先在所属assembly局部边界内定位;例如模块右侧板X最大面对齐所属总成X最大面,应写self_anchor=max,target=assembly,target_anchor=max,实际坐标由求解器计算。只有部件确实位于整件产品最外边界时才能直接锚定envelope。 +固定写contact_derivation=aabb_within_assembly_v1。AI只声明每个物理件所属总成,同一总成内的固定/支撑接触由编译器根据真实几何正面积相交自动推导,禁止AI猜固定连接面。 +每个assembly必须至少有一个实例,不能创建空root。每个assembly必须给出严格bounds_mm,边界等于所属实体并集的真实包围盒,不能把整件产品边界复制给局部模块。尺寸图或多视图显示上下分段时,按真实结构分成上下模块;上部模块的Z最小值必须来自分段尺寸或可追溯派生参数,其侧板不得再次落到产品Z=0。 +抽屉的前/后/左/右帮板和底板属于同一drawer总成;柜体板属于carcass,不能把“抽屉”运动性质挂在某一块帮板上。层板、底板和抽屉帮板必须按相邻侧板之间的净空生成,不能按产品总宽横穿侧板;任何实体正体积穿插都必须修正。 +每个会独立运动的抽屉、平开门或移门必须是独立assembly;单块门板也要建立包含一个实例的总成。不同总成之间用assembly_connections,a/b只能引用assemblies中的稳定ID,禁止引用零件实例ID:fixed必须在最终几何中真实接触;sliding必须有axis与travel_limits_mm;hinged必须有axis与angle_limits_deg。connections中不要生成fixed/support。 +不同规格独立part;相同规格由多个instances引用,BOM数量只等于真实实例数,不额外产生总成板。 +所有运动连接必须引用真实实例和真实总成,不能用大公差掩盖错位。 +只画现有图像支持的结构;不要根据常见柜子想象层板数量。未标注内部尺寸先由AI按多图比例估算并标记estimated,生成评审草案; +只有原图完全不可见且对制造/成本高度敏感、经过局部复读与CAD反向复核仍不能判定的字段,才进入用户确认候选。 +不得引用公司BOM或产品名称模板生成几何,不得生成source_type=user。 +当前编译器只支持正交box板件;复杂曲面仍须保留uncertainties,不能宣称已精确复刻。 +输出紧凑有效JSON,最多40个parts、100个instances,不输出油漆材料、成本或孔位模板。""" + + +def render_multiview(ai3d, build_dir: pathlib.Path, step_path: str) -> list[dict]: + """Render orthographic evidence so the reviewer is not trapped in one ISO view.""" + python = str(ai3d.runtime_dir / "venv/bin/python") + snapshot = str(ai3d.skill_dir / "scripts/snapshot") + outputs = [ + {"path": str(build_dir / f"cad-{camera}.png"), "camera": camera} + for camera in ("front", "right", "top", "iso") + ] + ai3d._run_trusted_snapshot( + build_dir, + [python, snapshot, "--job", "-", "--json"], + stdin=json.dumps({ + "input": step_path, "mode": "view", "outputs": outputs, + "render": {"viewLabels": True, "padding": 0.1, "sizeProfile": "diagnostic"}, + }), + timeout=ai3d.timeout, + ) + return [{"camera": item["camera"], "path": item["path"]} for item in outputs if pathlib.Path(item["path"]).is_file()] + + +def cad_source(compiled, manufacturing_plan=None): + lines = [ + "from build123d import Align, Axis, Box, Cylinder, Pos, Compound, Color", + "", "def gen_step():", " parts = []", + ] + colors = [(0.72, 0.83, 0.96), (0.96, 0.77, 0.45), (0.8, 0.65, 0.86), (0.5, 0.82, 0.67)] + feature_map = (manufacturing_plan or {}).get("features_by_instance") or {} + manufacturing_geometry = (manufacturing_plan or {}).get("manufacturing_geometry_by_instance") or {} + for index, inst in enumerate(compiled["instances"]): + adjusted = manufacturing_geometry.get(str(inst["id"])) or {} + size = adjusted.get("size_mm") or inst["size_mm"] + pos = adjusted.get("position_mm") or inst["position_mm"] + center = [pos[axis] + size[axis] / 2 for axis in range(3)] + lines.extend([ + f" part = Box({size[0]!r}, {size[1]!r}, {size[2]!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos({center[0]!r}, {center[1]!r}, {center[2]!r}))", + ]) + for feature_index, feature in enumerate((feature_map.get(str(inst["id"])) or {}).get("holes") or [], 1): + if feature.get("validation_status") != "valid": + continue + world = feature["world_center_mm"] + axis = feature["axis"] + rotate = ".rotate(Axis.Y, 90)" if axis == "x" else ".rotate(Axis.X, 90)" if axis == "y" else "" + lines.extend([ + f" hole_{index}_{feature_index} = Cylinder({float(feature['diameter_mm']) / 2!r}, {float(feature['depth_mm'])!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER)){rotate}.moved(Pos({world['x']!r}, {world['y']!r}, {world['z']!r}))", + f" part = part - hole_{index}_{feature_index}", + ]) + for feature_index, feature in enumerate((feature_map.get(str(inst["id"])) or {}).get("grooves") or [], 1): + if feature.get("validation_status") != "valid": + continue + world, groove_size = feature["world_center_mm"], feature["world_size_mm"] + lines.extend([ + f" groove_{index}_{feature_index} = Box({groove_size['x']!r}, {groove_size['y']!r}, {groove_size['z']!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(Pos({world['x']!r}, {world['y']!r}, {world['z']!r}))", + f" part = part - groove_{index}_{feature_index}", + ]) + lines.extend([ + f" part.label = {str(inst['id'])!r}", + f" part.color = Color{colors[index % len(colors)]!r}", " parts.append(part)", + ]) + lines.append(" return Compound(children=parts, label='assembly_graph_draft')") + return "\n".join(lines) + "\n" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--engine", required=True) + parser.add_argument("--assets", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--cases", default="book,slide") + parser.add_argument("--model", default="qwen3.5-plus") + parser.add_argument("--rounds", type=int, default=2) + parser.add_argument( + "--material-family", default="engineered_panel", + choices=("engineered_panel", "solid_wood", "metal", "plastic", "glass", "other"), + help="Authoritative user-selected material family; never inferred from the image.", + ) + args = parser.parse_args() + out = pathlib.Path(args.output) + out.mkdir(parents=True, exist_ok=True) + os.environ["DALONGXIA_SOLID_WOOD_COST_STATE_DIR"] = str(out / "isolated-cost-state") + os.environ["DALONGXIA_AI_3D_STATE_DIR"] = str(out / "isolated-cad-state") + os.environ["DALONGXIA_SOLID_WOOD_HISTORY_BOM_ENABLED"] = "0" + spec = importlib.util.spec_from_file_location("graph_probe_engine", args.engine) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + engine = mod.SolidWoodCostEngine() + engine.model = args.model + ai3d = engine._load_ai3d() + reports = [] + for case in args.cases.split(","): + case_dir = out / case + case_dir.mkdir(exist_ok=True) + images = [] + source_assets = [] + image_ids = set() + for index, role in enumerate(("front", "side", "third", "dimension"), 1): + source = source_for_role(pathlib.Path(args.assets), case, role) + if source is None: + raise RuntimeError(f"missing source view: {case}/{role}") + image_id = f"image{index}" + image_ids.add(image_id) + mime = "image/png" if source.suffix == ".png" else "image/jpeg" + data_url = f"data:{mime};base64," + base64.b64encode(source.read_bytes()).decode() + images.extend([{"type": "text", "text": f"原始图ID={image_id};候选视角={role},需按图片内容复核。"}, + {"type": "image_url", "image_url": {"url": data_url}}]) + source_assets.append({ + "asset_id": index, "role": role, "kind": "image_base64", "value": data_url, + "filename": source.name, "validation_status": "accepted", + }) + report = {"case": case, "schema_version": SCHEMA_VERSION, "model": args.model, + "material_family": args.material_family, "full_chat_api": False, + "company_bom_used_in_inference": False, "rounds": [], "status": "not_validated"} + print(json.dumps({"case": case, "phase": "ai_source_evidence_qualification"}), flush=True) + source_structure, source_dimensions = {}, {} + try: + source_structure = engine._read_source_structure(source_assets) + except Exception as exc: + source_structure = {"status": "machine_reread_failed", "error": f"{type(exc).__name__}: {exc}"} + try: + source_dimensions = engine._read_source_dimension_labels( + source_assets[-1], [source_assets[0], source_assets[1]] + ) + except Exception as exc: + source_dimensions = {"status": "machine_reread_failed", "error": f"{type(exc).__name__}: {exc}"} + dimension_ledger = build_dimension_ledger( + source_dimensions.get("overall_dimension_labels") or [], source_image_id="image4" + ) + topology_contract = build_source_topology_contract(source_structure) + report["source_qualification"] = { + "structure": source_structure, + "dimensions": source_dimensions, + "dimension_ledger": dimension_ledger, + "topology_contract": topology_contract, + "policy": "AI先交叉识别和CAD复核;只有不可见且成本敏感的残余冲突才询问用户", + } + + def checkpoint(current_row=None): + snapshot = json.loads(json.dumps(report, ensure_ascii=False)) + if current_row is not None: + snapshot["rounds"] = list(snapshot.get("rounds") or []) + [current_row] + (case_dir / "report.json").write_text( + json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + checkpoint() + missing_axes = [axis for axis in "xyz" if axis not in dimension_ledger] + if missing_axes or dimension_ledger.get("conflicts"): + report["status"] = "source_dimensions_not_qualified" + report["source_qualification"]["blocking"] = { + "code": "overall_dimension_ledger_incomplete", + "missing_axes": missing_axes, + "conflicts": dimension_ledger.get("conflicts") or [], + "next_owner": "ai_focused_source_reread", + } + reports.append(report) + (case_dir / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + (out / "report.json").write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({ + "case": case, "status": report["status"], + "blocking": report["source_qualification"]["blocking"], + }, ensure_ascii=False), flush=True) + continue + evidence_context = ( + "\n以下是同一批原图经过两个独立AI专项读取后的候选证据。它们不是公司BOM,也不是最终答案;" + "你必须回看原图。dimension_ledger中存在的X/Y/Z是已通过范围、方向和置信度门的整体尺寸,必须精确采用;" + "structure.view_adjudication.accepted是跨视图成立的结构约束;conflicts和machine_followups应由本轮图像复核解决," + "不能直接转嫁给用户。\n" + json.dumps({ + "structure": source_structure, + "dimension_read": source_dimensions, + "dimension_ledger": dimension_ledger, + "topology_contract": topology_contract, + }, ensure_ascii=False) + ) + graph, feedback = {}, [] + for turn in range(max(1, min(args.rounds, 3))): + started = time.time() + row = {"round": turn + 1} + prompt = GRAPH_PROMPT + evidence_context + if graph: + prompt = ( + "你在修正一份已存在的参数化家具结构图谱。必须回看本轮所有原图,并只返回局部JSON修改,不能返回整份图谱。" + "每条操作为add/replace/remove和JSON Pointer路径;修复引用、参数、实例位置、接触面或遗漏部件。" + "不能通过放大公差、删除有原图证据的部件/连接或改低置信度来逃避问题。" + "一次最多24条patch,优先级固定为:原图整体尺寸冲突、总成局部边界错误、实体穿插、已定义却无实例的部件、总成归属/总成关系错误、几何未真实接触、其他外观差异。" + "若零件observed_quantity大于实例数,必须为每个真实物理件补instances,不能只保留零件定义。" + "同一总成的固定关系不准手写connections;必须修正部件尺寸、placement锚点或assembly_id,让求解器计算坐标并从真实接触面推导连接。" + "anchored_axes_v1下严禁修改不存在的position_mm;必须修改总成bounds_mm、零件size_mm或placement锚点/offset_mm。" + "每个板件必须保留functional_role和本地thickness_axis;编译器会用它区分面贴面、边贴面与边贴边,不允许用名称代替接口语义。" + "修正定位时优先使用稳定实例ID。若某轴当前没有value_mm或offset_mm,不得replace不存在的叶子字段;" + "必须replace整个轴对象,或对align轴用add新增offset_mm。" + "合法示例:{\"patches\":[{\"op\":\"replace\",\"path\":\"/instances/door_left_inst/placement/x\"," + "\"value\":{\"mode\":\"absolute\",\"value_mm\":20}}]}。" + "路径可修改observations、parameters、envelope_mm、assemblies、parts、instances、connections、assembly_connections、uncertainties、placement_solver、contact_derivation;" + "数组追加使用/-。修改后仍需符合furniture-assembly-graph/1。\n上一版图谱:" + + json.dumps(graph, ensure_ascii=False) + + evidence_context + + "\n编译器及原图视觉复核的结构化反馈:" + json.dumps(feedback, ensure_ascii=False) + ) + phase = "ai_graph_from_original_images" if not graph else "ai_local_graph_repair_from_feedback" + print(json.dumps({"case": case, "round": turn + 1, "phase": phase}), flush=True) + try: + response = engine._request_model_json({ + "model": args.model, "messages": [{"role": "user", "content": images + [{"type": "text", "text": prompt}]}], + "response_format": {"type": "json_object"}, "temperature": 0.0, + "enable_thinking": True, "thinking_budget": 2048, "vl_high_resolution_images": True, "max_tokens": 14000, + }, "原图到参数化结构图谱失败", max_attempts=1, timeout_seconds=180, + decoder=lambda text: json.loads(text)) + if graph: + row["patch_response"] = response + try: + graph, patch_audit = apply_graph_patch(graph, response.get("patches")) + row["patch_audit"] = patch_audit + except GraphError as exc: + row["patch_error"] = f"{type(exc).__name__}: {exc}" + feedback = structured_patch_feedback(graph, response.get("patches"), exc) + row["patch_feedback"] = feedback + report["status"] = "patch_not_applied" + row["checkpoint_phase"] = "invalid_patch_rejected_graph_preserved" + checkpoint(row) + else: + graph = response + if not row.get("patch_error"): + row["graph"] = graph + compiled = compile_graph( + graph, source_image_ids=image_ids, dimension_ledger=dimension_ledger, + topology_contract=topology_contract, + ) + row["compiled"] = compiled + feedback = list(compiled["issues"]) + manufacturing_plan = plan_manufacturing_features( + compiled, + policy={"default_material_family": args.material_family}, + ) + row["manufacturing_plan"] = manufacturing_plan + if compiled["ok"]: + feedback += list(manufacturing_plan.get("issues") or []) + row["checkpoint_phase"] = "graph_compiled" + checkpoint(row) + if compiled["instances"]: + build_dir = case_dir / f"round-{turn + 1}" + build_dir.mkdir(exist_ok=True) + source = build_dir / "assembly.step.py" + source.write_text(cad_source(compiled, manufacturing_plan), encoding="utf-8") + ai3d._validate_source(source.read_text()) + try: + built = ai3d._build_and_validate(build_dir, source.name, requested_exports=["glb"]) + except Exception as exc: + row["cad_kernel_error"] = f"{type(exc).__name__}: {exc}" + kernel_feedback = structured_kernel_feedback(exc) + row["cad_kernel_feedback"] = kernel_feedback + feedback += kernel_feedback + report["status"] = ( + "cad_kernel_not_passed" if compiled["ok"] + else "graph_and_cad_kernel_not_passed" + ) + row["checkpoint_phase"] = "cad_kernel_rejected" + checkpoint(row) + else: + built["multiview_previews"] = render_multiview(ai3d, build_dir, built["step_path"]) + row["built" if compiled["ok"] else "diagnostic_built"] = built + row["checkpoint_phase"] = "cad_built_before_visual_review" + checkpoint(row) + cad_views = [] + for item in built["multiview_previews"]: + view_path = pathlib.Path(item["path"]) + cad_views.extend([ + {"type": "text", "text": f"候选CAD {item['camera']}正交视图(不是原图证据):"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(view_path.read_bytes()).decode()}}, + ]) + review = engine._request_model_json({ + "model": args.model, "messages": [{"role": "user", "content": images + cad_views + [ + {"type": "text", "text": ( + "必须分别使用CAD正面、右侧、俯视和等轴视图,对照全部原始图核验CAD侧板分段、抽屉排列、" + "所有可见板件数量、门型、部件方向、连接关系和外形比例。" + "这是诊断草图,编译错误中的零件可能未渲染;不能因为画面简单就判正确。" + "先由AI给出可执行correction;uncertain仅保留所有原图和四个CAD视图都不能消解的隐蔽信息。" + "只返回JSON {ok:boolean,issues:[{code,component,source_image_id,evidence,correction}],uncertain:[]}。" + "每个问题引用一张能证明问题的原图ID;没有原图证据的推测放uncertain。\n编译器问题:" + + json.dumps(compiled["issues"], ensure_ascii=False) + + "\n当前由物理实例生成的BOM:" + json.dumps(compiled["bom"], ensure_ascii=False) + + "\n由真实接触面生成的孔槽与连接件计划:" + json.dumps(manufacturing_plan, ensure_ascii=False) + )}, + ]}], "response_format": {"type": "json_object"}, "temperature": 0.0, + "enable_thinking": True, "thinking_budget": 2048, "vl_high_resolution_images": True, "max_tokens": 6000, + }, "原图与结构图谱CAD复核失败", max_attempts=1, timeout_seconds=150, decoder=lambda text: json.loads(text)) + row["visual_review"] = review + feedback = list(compiled["issues"]) + list(review.get("issues") or []) + list(review.get("uncertain") or []) + if compiled["ok"] and manufacturing_plan.get("ok") and review.get("ok") is True and not feedback and not graph.get("uncertainties"): + report["status"] = "candidate_visual_pass_requires_independent_acceptance" + else: + report["status"] = "visual_not_passed" if compiled["ok"] else "graph_not_passed_with_diagnostic_render" + else: + report["status"] = "graph_not_passed" + except Exception as exc: + row["error"] = f"{type(exc).__name__}: {exc}" + report["status"] = "failed" + feedback = [{"code": "request_or_compile_error", "detail": row["error"]}] + row["elapsed_seconds"] = round(time.time() - started, 2) + row.pop("checkpoint_phase", None) + report["rounds"].append(row) + (case_dir / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({"case": case, "round": turn + 1, "status": report["status"], "elapsed": row["elapsed_seconds"], "feedback": feedback[:8]}, ensure_ascii=False), flush=True) + if report["status"].startswith("candidate_visual_pass"): + break + reports.append(report) + (out / "report.json").write_text(json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/references/production-drawing-system.md b/server_patch/ai_3d_model/ai-solid-wood-cost/references/production-drawing-system.md new file mode 100644 index 0000000..dc02831 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/references/production-drawing-system.md @@ -0,0 +1,109 @@ +# 家具生产工艺图与材料连接规则 + +## 目标与边界 + +这一子系统把已确认的材料、板厚、BOM角色、五金模板与CAD几何统一成一份制造特征图。估价CAD不等于已发布生产图;只有完成硬校验和结构工程师签批后才可下料。 + +## 材料到连接工艺的硬约束 + +| 材料类型 | 允许的主连接 | 禁止/限制 | +| --- | --- | --- | +| 纯实木 | 传统榫卯、定位木销、木螺钉、五金连接 | 传统榫卯必须明确榫头/榫眼尺寸、纤维方向和配合公差;仅有图像疑似不能发布 | +| 密度板/刨花板/实木多层板 | 确认螺钉、三合一、定位木销、五金件、背板/抽底槽 | 禁止传统承力榫卯;木销只是定位件,不得在图纸或费用中标为榫卯 | +| 金属 | 焊接、螺栓、螺母、铆钉、铆螺母 | 不生成木工榫卯;焊缝未有符号、焊脚/焊透尺寸、长度和夹具基准时只能出草案 | +| 木/板材+金属 | 预埋螺母、连接片、角码、穿透螺栓 | 材料界面不允许默认传统榫卯,必须标注金属件与孔位模板 | + +输入中一旦出现不兼容工艺,系统必须将它放入 `rejected_incompatible_features`,对应数量和成本归零,不能默默保留。 + +背板、抽屉底板等捕获式薄板不能只在承载板上画槽。每个入槽接触必须同时生成:承载件槽深、槽宽/装配余量、薄板对应边的入槽延伸量,以及使用延伸后成品尺寸的制造BOM。组装CAD必须以该成品尺寸伸入已切槽实体;若仍有正体积干涉、薄板只停在槽口或槽宽小于板厚加余量,自动发布检查失败。 + +## 制造特征数据结构 + +每个零件使用“左-前-下角”为局部原点,单位固定为mm。不可用零件图片的像素坐标替代制造坐标。 + +```json +{ + "part_id": "SIDE-L-001", + "datum": "左-前-下角", + "dimensions_mm": {"x": 12, "y": 400, "z": 850}, + "holes": [{ + "feature_id": "HOLE-0001", + "face": "X-", + "axis": "x", + "center_mm": {"x": 6, "y": 100, "z": 12}, + "diameter_mm": 4, + "depth_mm": 13, + "hole_type": "through", + "dimension_tolerance_mm": 0.10, + "position_tolerance_mm": 0.15, + "hardware_template_id": "DLX-CONFIRMAT-5X30-V1" + }], + "grooves": [{ + "feature_id": "GROOVE-0002", + "center_mm": {"x": 6, "y": 390.5, "z": 425}, + "size_mm": {"x": 3.5, "y": 3, "z": 834}, + "dimension_tolerance_mm": 0.15, + "bottom_radius_max_mm": 1.5 + }] +} +``` + +生成坐标超界时可为了CAD预览记录安全显示坐标,但必须同时保留 `requested_center_mm`、标记 `validation_status=blocked`并阻断生产发布;不允许静默夹取坐标后冒充合格。 + +## 图纸交付与发布状态 + +- PDF:首页是发布状态与阻断项,后续每个零件一页,显示材料、数量、总尺寸、基准、主视图和孔槽表。 +- DXF ZIP:每个零件一个R12 DXF,分 `OUTLINE` / `HOLE` / `GROOVE` / `DIMENSION` 图层。DXF用于交换,机台NC代码必须再经设备专用后处理器生成。 +- JSON:包含完整制造特征、规则版本、阻断项和发布状态,是第三方界面判断是否显示“可下料”的唯一依据。 + +状态定义: + +1. `drawing_draft`:可用于成本估算和技术评审,不得下料。 +2. `production_ready`:自动材料、尺寸、孔槽、五金模板、BOM/CAD一致性检查通过,但仍待签批。 +3. `production_released`:结构工程师签批后的不可变更版本,只有该状态允许工厂下料。 + +当前引擎交付的自动图包不伪造人工签批,因此默认 `release_status=drawing_draft`;当自动检查全部通过时另外返回 `automated_validation_status=production_ready`。 + +自动检查的最低顺序固定为:图谱尺寸/总成边界 → 材料与工艺兼容 → 孔槽边界 → 实际布尔切削 → 零件两两B-rep正体积干涉 → BOM/五金/工时唯一ID一致 → 正交截面与多视图复核。仅验证每个单体闭合,不能替代装配干涉检查;仅在JSON中存在孔槽,也不能替代切削后面数/体积或截面证据。 + +## Chat API 第三方前端支持 + +`response.files[]` 新增三个文件角色: + +| artifact_role | MIME | 前端行为 | +| --- | --- | --- | +| `production_drawing_manifest` | `application/json` | 读取发布状态、阻断项、零件与孔槽;不平铺内部JSON | +| `production_drawing_pdf` | `application/pdf` | 显示“预览/下载生产图” | +| `production_drawing_dxf_bundle` | `application/zip` | 显示“下载DXF图包”,不尝试在对话中渲染为普通文本 | + +前端必须按JSON中状态渲染: + +- `release_status != production_released`:显示红色“未批准不得下料”,列出 `release_blockers`,不显示“发送工厂”。 +- `automated_validation_status = production_ready`且待签批:显示黄色“自动检查通过,待工程审批”。 +- `release_status = production_released`:显示绿色已发布标识,同时显示版本号、签批人和文件SHA-256。 + +第三方不得仅根据PDF/DXF文件存在就判定可生产。 + +## 正式依据 + +- 家具制图:QB/T 1338-2012 + https://std.samr.gov.cn/hb/search/stdHBDetailed?id=8B1827F1BE7ABB19E05397BE0A0AB44A +- 木家具通用技术条件:GB/T 3324-2024 + https://std.samr.gov.cn//gb/search/gbDetailed?id=25940C3CEF158A9AE06397BE0A0A525A +- 家具结构安全技术规范:GB 28008-2024 + https://std.samr.gov.cn/gb/search/gbDetailed?id=1BD5B9878E612FCEE06397BE0A0A10CC +- 图纸幅面和格式:GB/T 14689-2008 + https://std.samr.gov.cn/search/stdPage?q=GBT14689 +- 机械制图 尺寸注法:GB/T 4458.4-2003 + https://std.samr.gov.cn/gb/search/gbDetailed?id=cea9cUOkNBU%3D&mode=p +- 未注一般公差:GB/T 1804-2000 + https://std.samr.gov.cn/gb/search/gbDetailed?id=71F772D783B2D3A7E05397BE0A0AB82A +- 焊缝符号:GB/T 324-2008 + https://std.samr.gov.cn/gb/search/gbDetailedCNF?id=71F772D77B8DD3A7E05397BE0A0AB82A +- 焊接结构一般公差:GB/T 19804-2005 + https://std.samr.gov.cn/gb/search/gbDetailed?id=71F772D7835FD3A7E05397BE0A0AB82A +- Blum官方目录说明BXF/CAD数据包含加工图、裁切尺寸和钻孔位置: + https://publications.blum.com/2022/catalogue/zh/600/ +- Hettich System 32排钻模板和五金DXF/DWG下载页: + https://shop.hettich.com/us_EN/Further-products/Assembly-machinery/Drilling-jigs/Drilling-jigs-for-hole-lines/Accura-for-hole-lines/Accura-1000-mm-drilling-template%2C-aluminium-with-3-stop-marking-pins/p/10573 + https://shop.hettich.com/bh_EN/Hinges/Concealed-hinges/Intermat-fast-assembly-concealed-hinges/Hinges/110%C2%B0-Intermat-hinge-without-self-closing-feature-%28Intermat-9973%29%2C-overlay%2C-Opening-angle-110%C2%B0%2C-TH-drilling-pattern-52-x-5-5-mm%2C-for-screwing-on-%28-%29/p/9043361 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py new file mode 100644 index 0000000..33ab236 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/structure_graph.py @@ -0,0 +1,1150 @@ +"""Product-independent, evidence-backed parametric assembly compiler. + +This candidate does not infer geometry from Chinese part names. AI proposes a +graph; this module resolves typed dimensions, derives BOM quantities from real +instances and reports geometric contradictions for the AI to repair. It is an +isolated compiler, NOT a certificate that the images or design are correct. +""" +from __future__ import annotations + +import ast +import hashlib +import json +import math +import re +import copy +from collections import Counter + + +SCHEMA_VERSION = "furniture-assembly-graph/1" + + +def build_source_topology_contract(source_structure: dict, *, min_confidence: float = 0.82) -> dict: + """Promote only strongly evidenced source-part counts into a compile gate. + + This is image-derived evidence, never a company-BOM answer. It prevents a + later graph-generation pass from silently omitting a part already counted + by an independent multi-view pass. Invisible or low-confidence structure + deliberately remains outside the hard contract. + """ + allowed_roles = { + "top", "bottom", "side", "shelf", "door", "back", "drawer_front", + "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", + "drawer_support", "divider", "leg", "rail", "stile", "frame_member", + "hardware", + } + role_counts = Counter() + evidence: dict[str, list[dict]] = {} + rows = source_structure.get("parts") if isinstance(source_structure, dict) else [] + for row in rows or []: + if not isinstance(row, dict): + continue + role = str(row.get("component_role") or row.get("functional_role") or "") + confidence = row.get("confidence") + image_ids = [ + value for value in (row.get("image_ids") or []) + if isinstance(value, int) and not isinstance(value, bool) + ] + quantity = row.get("quantity") + if ( + role not in allowed_roles + or isinstance(confidence, bool) + or not isinstance(confidence, (int, float)) + or not math.isfinite(confidence) + or float(confidence) < float(min_confidence) + or not image_ids + or isinstance(quantity, bool) + or not isinstance(quantity, int) + or quantity <= 0 + ): + continue + role_counts[role] += quantity + evidence.setdefault(role, []).append({ + "source_structure_id": str(row.get("source_structure_id") or ""), + "image_ids": sorted(set(image_ids)), + "confidence": round(float(confidence), 4), + "quantity": quantity, + }) + return { + "source": "independent_multiview_source_structure", + "minimum_confidence": float(min_confidence), + "role_counts": dict(sorted(role_counts.items())), + "evidence": evidence, + } + + +class GraphError(ValueError): + pass + + +def build_dimension_ledger(labels: list[dict], *, source_image_id: str) -> dict: + """Accept only unambiguous overall arrows read from the source drawing. + + Multiple equal readings are corroboration; materially different readings + on the same axis are retained as a conflict and deliberately not converted + into a guessed CAD constraint. + """ + unit_scale = {"mm": 1.0, "cm": 10.0, "m": 1000.0} + candidates = {axis: [] for axis in "xyz"} + rejected = [] + for index, row in enumerate(labels or []): + if not isinstance(row, dict): + continue + axis, unit = row.get("axis"), row.get("unit") + confidence = row.get("confidence") + value = row.get("value") + if ( + axis not in candidates or row.get("scope") != "overall" or unit not in unit_scale + or isinstance(value, bool) or not isinstance(value, (int, float)) + or isinstance(confidence, bool) or not isinstance(confidence, (int, float)) + or confidence < 0.8 or value <= 0 + ): + rejected.append({"index": index, "reason": "not_unambiguous_overall_arrow"}) + continue + candidates[axis].append({ + "value_mm": float(value) * unit_scale[unit], + "confidence": min(1.0, float(confidence)), + "source_image_ids": [source_image_id], + "text": str(row.get("text") or "")[:120], + "location": str(row.get("location") or "")[:200], + }) + ledger = {"conflicts": [], "rejected": rejected} + for axis, rows in candidates.items(): + if not rows: + continue + values = [row["value_mm"] for row in rows] + tolerance = max(1.0, 0.005 * max(values)) + if max(values) - min(values) > tolerance: + ledger["conflicts"].append({ + "axis": axis, + "values_mm": values, + "source_image_ids": sorted({source for row in rows for source in row["source_image_ids"]}), + }) + continue + best = max(rows, key=lambda row: row["confidence"]) + ledger[axis] = {**best, "value_mm": sum(values) / len(values), "tolerance_mm": tolerance} + return ledger + + +def apply_graph_patch(graph: dict, patches: list[dict]) -> tuple[dict, list[dict]]: + """Apply a bounded RFC6902 subset and return an immutable audit trail.""" + if not isinstance(patches, list) or not 1 <= len(patches) <= 64: + raise GraphError("图谱局部修改必须包含1到64条操作") + result = copy.deepcopy(graph) + allowed_roots = { + "observations", "parameters", "envelope_mm", "parts", "instances", + "connections", "uncertainties", "constraint_solver", "contact_derivation", + "placement_solver", "assemblies", "assembly_connections", + } + audit = [] + + def list_position(rows, token, path): + """Resolve a JSON array index or a stable object ``id``. + + Model-authored repairs naturally refer to ``/assemblies/drawer-1`` or + ``/instances/left-side``. Requiring a volatile numeric array index made + otherwise valid repairs fail or target the wrong object after an + insertion. Stable IDs are therefore part of the patch contract. + """ + if isinstance(token, str) and re.fullmatch(r"0|[1-9]\d*", token): + position = int(token) + if not 0 <= position < len(rows): + raise GraphError(f"修改路径不存在: {path}") + return position + matches = [ + index for index, row in enumerate(rows) + if isinstance(row, dict) and str(row.get("id") or "") == str(token) + ] + if len(matches) != 1: + raise GraphError(f"修改路径中的稳定ID不存在或不唯一: {path}") + return matches[0] + + def tokens(path): + if not isinstance(path, str) or not path.startswith("/"): + raise GraphError("局部修改路径必须是JSON Pointer") + values = [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")] + if not values or values[0] not in allowed_roots or any(token in {"__class__", "__dict__"} for token in values): + raise GraphError(f"禁止修改图谱路径: {path}") + return values + + for index, patch in enumerate(patches): + if not isinstance(patch, dict) or patch.get("op") not in {"add", "replace", "remove"}: + raise GraphError(f"第{index + 1}条修改操作无效") + path = patch.get("path") + parts = tokens(path) + parent = result + for token in parts[:-1]: + try: + parent = parent[list_position(parent, token, path)] if isinstance(parent, list) else parent[token] + except (KeyError, IndexError, TypeError, ValueError) as exc: + raise GraphError(f"修改路径不存在: {path}") from exc + leaf = parts[-1] + old = None + try: + if isinstance(parent, list): + if leaf == "-": + if patch["op"] != "add": + raise GraphError(f"只有add允许使用数组末尾路径: {path}") + parent.append(copy.deepcopy(patch.get("value"))) + else: + position = list_position(parent, leaf, path) + if patch["op"] == "add": + parent.insert(position, copy.deepcopy(patch.get("value"))) + else: + old = copy.deepcopy(parent[position]) + if patch["op"] == "replace": + parent[position] = copy.deepcopy(patch.get("value")) + else: + parent.pop(position) + else: + if patch["op"] == "add": + old = copy.deepcopy(parent.get(leaf)) + parent[leaf] = copy.deepcopy(patch.get("value")) + else: + if leaf not in parent: + raise KeyError(leaf) + old = copy.deepcopy(parent[leaf]) + if patch["op"] == "replace": + parent[leaf] = copy.deepcopy(patch.get("value")) + else: + del parent[leaf] + except (KeyError, IndexError, TypeError, ValueError) as exc: + if isinstance(exc, GraphError): + raise + raise GraphError(f"无法执行修改路径: {path}") from exc + audit.append({"index": index + 1, "op": patch["op"], "path": path, + "old": old, "new": copy.deepcopy(patch.get("value")) if patch["op"] != "remove" else None}) + return result, audit + + +class ParameterResolver: + def __init__(self, parameters: dict, observations: dict): + self.parameters = parameters + self.observations = observations + self.resolved = {} + self.visiting = set() + + @staticmethod + def finite(value): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise GraphError("必须为有限数值") + return float(value) + + def parameter(self, key): + if key in self.resolved: + return self.resolved[key] + if key in self.visiting: + raise GraphError(f"参数依赖成环: {key}") + if key not in self.parameters: + raise GraphError(f"参数不存在: {key}") + self.visiting.add(key) + try: + spec = self.parameters[key] + kind = spec.get("kind") + dimension = 1 if kind == "length" else 0 + if kind not in {"length", "count", "scalar"}: + raise GraphError(f"参数 {key} 缺少明确类型") + origin = spec.get("origin") + refs = spec.get("evidence_ids") or [] + if origin == "derived": + value, actual_dimension, confidence, estimated = self.expression(spec.get("expression")) + if actual_dimension != dimension: + raise GraphError(f"参数 {key} 派生式量纲错误") + elif origin in {"observed", "estimated", "user"}: + if origin in {"observed", "user"} and ( + not refs or any(ref not in self.observations for ref in refs) + ): + raise GraphError(f"参数 {key} 缺少可回溯证据") + if origin == "observed" and any(self.observations[ref].get("source_type") != "image" for ref in refs): + raise GraphError(f"参数 {key} 图像事实引用了非原图证据") + if origin == "user" and any(self.observations[ref].get("source_type") != "user" for ref in refs): + raise GraphError(f"参数 {key} 不能把AI判断写成用户确认") + value = self.finite(spec.get("value")) + unit = spec.get("unit") + if dimension: + if unit not in {"mm", "cm", "m"}: + raise GraphError(f"参数 {key} 长度单位不明确") + value *= {"mm": 1, "cm": 10, "m": 1000}[unit] + elif unit not in {None, "", "1", "件"}: + raise GraphError(f"参数 {key} 无量纲参数不应带长度单位") + confidence = min([self.finite(spec.get("confidence", 0))] + + [self.finite(self.observations[ref].get("confidence", 0)) for ref in refs]) + estimated = origin == "estimated" + if estimated: + confidence = min(confidence, 0.55) + else: + raise GraphError(f"参数 {key} 缺少来源类型") + if kind == "count" and (value < 0 or value != int(value)): + raise GraphError(f"参数 {key} 数量必须是非负整数") + if not 0 <= confidence <= 1: + raise GraphError(f"参数 {key} 置信度超界") + result = (value, dimension, confidence, estimated) + self.resolved[key] = result + return result + finally: + self.visiting.remove(key) + + def expression(self, text, *, expected_dimension=None): + if not isinstance(text, str) or len(text) > 500: + raise GraphError("表达式必须是长度受限的参数算式") + try: + tree = ast.parse(text, mode="eval") + except (SyntaxError, ValueError) as exc: + raise GraphError("参数表达式语法错误") from exc + if sum(1 for _ in ast.walk(tree)) > 100: + raise GraphError("参数表达式过于复杂") + + def visit(node): + if isinstance(node, ast.Constant): + return self.finite(node.value), 0, 1.0, False, True + if isinstance(node, ast.Name): + return (*self.parameter(node.id), False) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): + value, dimension, confidence, estimated, literal = visit(node.operand) + return (-value if isinstance(node.op, ast.USub) else value), dimension, confidence, estimated, literal + if not isinstance(node, ast.BinOp) or not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): + raise GraphError("只允许参数、数字和加减乘除,不执行函数或代码") + a, da, ca, ea, la = visit(node.left) + b, db, cb, eb, lb = visit(node.right) + if isinstance(node.op, (ast.Add, ast.Sub)): + # Geometry fields use millimetres as their contextual unit. A + # literal offset in ``width-30`` therefore means 30 mm, while + # the literal in ``2*thickness`` remains dimensionless. Derived + # parameter definitions stay strict unless their caller supplies + # an expected length context. + if expected_dimension == 1 and da != db: + if la and da == 0 and db == 1: + da = 1 + elif lb and db == 0 and da == 1: + db = 1 + if da != db: + raise GraphError("相加/相减的量纲不同") + value, dimension = (a + b if isinstance(node.op, ast.Add) else a - b), da + elif isinstance(node.op, ast.Mult): + value, dimension = a * b, da + db + else: + if b == 0: + raise GraphError("参数表达式除以零") + value, dimension = a / b, da - db + self.finite(value) + return value, dimension, min(ca, cb), ea or eb, la and lb + + value, dimension, confidence, estimated, _literal = visit(tree.body) + return value, dimension, confidence, estimated + + def length(self, expression): + # Numeric geometry literals are explicitly millimetres under this schema. + if isinstance(expression, (int, float)) and not isinstance(expression, bool): + return self.finite(expression) + if isinstance(expression, str) and re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", expression.strip()): + return self.finite(float(expression)) + value, dimension, _, _ = self.expression(expression, expected_dimension=1) + if dimension != 1: + raise GraphError("尺寸/位置表达式的结果必须是长度") + return value + + +def _solve_orthogonal_contact_normals(instances: dict, connections: list[dict]) -> tuple[list[dict], list[dict]]: + """Solve only the normal-axis equation declared by each face mate. + + The AI owns topology (which faces mate); the solver owns arithmetic. It + never guesses tangential alignment or connection type. Every movement is + returned for audit, and contradictory cycles remain blocking errors. + """ + adjacency = {axis: {key: [] for key in instances} for axis in range(3)} + errors = [] + for index, edge in enumerate(connections): + if edge.get("kind") not in {"fixed", "support"}: + continue + a, b = instances.get(edge.get("a")), instances.get(edge.get("b")) + fa, fb = edge.get("face_a"), edge.get("face_b") + if not a or not b or fa not in {"x-", "x+", "y-", "y+", "z-", "z+"} or fb not in {"x-", "x+", "y-", "y+", "z-", "z+"}: + continue + if fa[0] != fb[0] or fa[1] == fb[1]: + continue + axis = "xyz".index(fa[0]) + offset_a = a["size_mm"][axis] if fa[1] == "+" else 0.0 + offset_b = b["size_mm"][axis] if fb[1] == "+" else 0.0 + delta = offset_a - offset_b # position_b = position_a + delta + adjacency[axis][a["id"]].append((b["id"], delta, index)) + adjacency[axis][b["id"]].append((a["id"], -delta, index)) + adjustments = [] + for axis, graph in adjacency.items(): + unseen = {key for key, links in graph.items() if links} + while unseen: + root = next(iter(unseen)) + potential = {root: 0.0} + pending = [root] + component = set() + conflict = False + while pending: + node = pending.pop() + component.add(node) + for other, delta, edge_index in graph[node]: + proposed = potential[node] + delta + if other in potential and abs(potential[other] - proposed) > 0.01: + errors.append({ + "code": "constraint_cycle_conflict", "path": f"/connections/{edge_index}", + "detail": f"{('xyz'[axis]).upper()}轴接触约束闭环互相矛盾{abs(potential[other]-proposed):.3f}mm", + "repair_owner": "ai", "axis": "xyz"[axis], "instance_ids": sorted(component | {other}), + }) + conflict = True + elif other not in potential: + potential[other] = proposed + pending.append(other) + unseen -= component + if conflict: + continue + grounded = [key for key in component if instances[key].get("grounded") is True] + if len(grounded) > 1: + translations = [instances[key]["position_mm"][axis] - potential[key] for key in grounded] + if max(translations) - min(translations) > 0.01: + errors.append({ + "code": "grounded_constraint_conflict", "path": "/instances", + "detail": f"多个固定实例在{('xyz'[axis]).upper()}轴与接触约束冲突", + "repair_owner": "ai", "axis": "xyz"[axis], "instance_ids": sorted(grounded), + }) + continue + anchor = grounded[0] if grounded else min(component, key=lambda key: instances[key]["position_mm"][axis]) + translation = instances[anchor]["position_mm"][axis] - potential[anchor] + for key in component: + old = instances[key]["position_mm"][axis] + new = potential[key] + translation + if abs(old - new) > 0.01: + instances[key]["position_mm"][axis] = new + adjustments.append({ + "instance_id": key, "axis": "xyz"[axis], "from_mm": old, "to_mm": new, + "reason": "orthogonal_contact_normal_constraint", + }) + return adjustments, errors + + +def _aabb_contact(a: dict, b: dict, *, tolerance_mm: float = 0.6) -> dict | None: + """Return one real boundary contact with positive tangential area.""" + matches = [] + for axis in range(3): + a_min = a["position_mm"][axis] + a_max = a_min + a["size_mm"][axis] + b_min = b["position_mm"][axis] + b_max = b_min + b["size_mm"][axis] + if abs(a_max - b_min) <= tolerance_mm: + matches.append((axis, "+", "-", abs(a_max - b_min))) + if abs(b_max - a_min) <= tolerance_mm: + matches.append((axis, "-", "+", abs(b_max - a_min))) + for axis, sign_a, sign_b, gap in matches: + overlaps = [ + min(a["position_mm"][other] + a["size_mm"][other], b["position_mm"][other] + b["size_mm"][other]) + - max(a["position_mm"][other], b["position_mm"][other]) + for other in range(3) if other != axis + ] + if min(overlaps) <= 0.01: + continue + return { + "a": a["id"], "b": b["id"], + "face_a": f"{'xyz'[axis]}{sign_a}", "face_b": f"{'xyz'[axis]}{sign_b}", + "gap_mm": gap, "overlap_mm": overlaps, + "source": "aabb_positive_area_contact", + } + return None + + +def _aabb_interference(a: dict, b: dict, *, tolerance_mm: float = 0.6) -> dict | None: + """Return a real solid-volume collision, ignoring face contact tolerance. + + The graph compiler currently models every instance as a closed solid box. + Two boxes may share a face, edge or point, but they may not occupy the same + volume. This catches shelves cutting through side panels and drawer walls + accidentally placed on top of one another. + """ + overlaps = [ + min(a["position_mm"][axis] + a["size_mm"][axis], b["position_mm"][axis] + b["size_mm"][axis]) + - max(a["position_mm"][axis], b["position_mm"][axis]) + for axis in range(3) + ] + if min(overlaps) <= tolerance_mm: + return None + return { + "a": a["id"], "b": b["id"], "overlap_mm": overlaps, + "overlap_volume_mm3": overlaps[0] * overlaps[1] * overlaps[2], + "source": "aabb_positive_volume_intersection", + } + + +def _derive_aabb_contacts(instances: dict, *, tolerance_mm: float = 0.6) -> list[dict]: + """Derive real positive-area contacts inside each declared assembly. + + This replaces an unreliable language-model task: the AI declares which + physical instance belongs to which assembly, while geometry determines + what actually touches. Cross-assembly motion (drawer/door to carcass) + remains explicit because a clearance joint should not geometrically touch. + """ + contacts = [] + rows = list(instances.values()) + for left_index, a in enumerate(rows): + for b in rows[left_index + 1:]: + if a.get("assembly_id") != b.get("assembly_id"): + continue + contact = _aabb_contact(a, b, tolerance_mm=tolerance_mm) + if contact: + contact_axis = contact["face_a"][0] + a_broad_face = a.get("thickness_axis") == contact_axis + b_broad_face = b.get("thickness_axis") == contact_axis + interface_type = ( + "face_to_face" if a_broad_face and b_broad_face + else "edge_to_face" if a_broad_face or b_broad_face + else "edge_to_edge" + ) + contacts.append({ + **contact, + "id": f"derived:{a['id']}:{b['id']}:{contact['face_a'][0]}", + "kind": "geometric_contact", + "assembly_id": a.get("assembly_id"), + "interface_type": interface_type, + "broad_face_instance_id": ( + a["id"] if a_broad_face and not b_broad_face + else b["id"] if b_broad_face and not a_broad_face + else None + ), + "edge_instance_id": ( + b["id"] if a_broad_face and not b_broad_face + else a["id"] if b_broad_face and not a_broad_face + else None + ), + }) + return contacts + + +def _solve_anchored_axis_placements( + instance_specs: dict, + *, + resolver: ParameterResolver, + envelope_mm: list[float], + assembly_bounds: dict | None = None, +) -> tuple[dict, list[dict]]: + """Resolve declared design intent into coordinates. + + The model names an anchor relationship (for example, the right side's + maximum X is aligned to the product envelope's maximum X). It never gets + to guess the resulting coordinate. References form a dependency graph; + cycles and missing axes are rejected instead of being numerically nudged. + """ + resolved = {} + audit = [] + visiting = set() + anchors = {"min": 0.0, "center": 0.5, "max": 1.0} + + def length(value): + return resolver.length(0 if value is None else value) + + def solve(instance_id): + if instance_id in resolved: + return resolved[instance_id] + if instance_id in visiting: + raise GraphError(f"实例定位依赖成环: {instance_id}") + if instance_id not in instance_specs: + raise GraphError(f"定位引用了不存在的实例: {instance_id}") + visiting.add(instance_id) + try: + spec = instance_specs[instance_id] + placement = spec.get("placement") + if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"}: + raise GraphError(f"实例 {instance_id} 必须分别声明X/Y/Z三轴定位约束") + position = [] + for axis, axis_name in enumerate("xyz"): + rule = placement[axis_name] + if not isinstance(rule, dict): + raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴定位无效") + mode = rule.get("mode") + if mode == "absolute": + coordinate = length(rule.get("value_mm")) + target = "absolute" + target_kind = "absolute" + elif mode == "align": + self_anchor = rule.get("self_anchor") + target_anchor = rule.get("target_anchor") + if self_anchor not in anchors or target_anchor not in anchors: + raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴锚点必须为min/center/max") + target_id = rule.get("target") + if target_id == "envelope": + target_position, target_size = 0.0, envelope_mm[axis] + target_kind = "envelope" + elif target_id in {"assembly", spec.get("assembly_id")}: + # A model naturally refers to its declared assembly by + # stable ID (for example ``carcass`` or ``drawer-1``). + # Treat only the instance's *own* assembly ID as the + # explicit ``assembly`` alias. Other assembly IDs do + # not silently become cross-assembly coordinates. + bounds = (assembly_bounds or {}).get(spec.get("assembly_id")) + if not bounds: + raise GraphError(f"实例 {instance_id} 引用了没有有效边界的所属总成") + target_position = bounds[axis][0] + target_size = bounds[axis][1] - bounds[axis][0] + target_kind = "assembly" + else: + target_instance = solve(target_id) + target_position = target_instance["position_mm"][axis] + target_size = target_instance["size_mm"][axis] + target_kind = "instance" + coordinate = ( + target_position + anchors[target_anchor] * target_size + - anchors[self_anchor] * spec["size_mm"][axis] + + length(rule.get("offset_mm")) + ) + target = target_id + else: + raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴只支持absolute/align定位") + if not math.isfinite(coordinate): + raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴定位不是有限数") + position.append(coordinate) + audit.append({ + "instance_id": instance_id, "axis": axis_name, + "coordinate_mm": coordinate, "mode": mode, "target": target, + "target_kind": target_kind, + "source": "declared_anchor_constraint", + }) + resolved[instance_id] = {**spec, "position_mm": position} + return resolved[instance_id] + finally: + visiting.remove(instance_id) + + for key in instance_specs: + solve(key) + return resolved, audit + + +def compile_graph( + graph: dict, + *, + source_image_ids: set | None = None, + user_evidence: dict | None = None, + dimension_ledger: dict | None = None, + topology_contract: dict | None = None, +) -> dict: + """Resolve a graph without repairing it by category-specific heuristics.""" + issues = [] + + def issue(code, path, detail, **context): + issues.append({"code": code, "path": path, "detail": detail, "repair_owner": "ai", **context}) + + if graph.get("schema_version") != SCHEMA_VERSION: + issue("schema_version", "/schema_version", "结构图谱版本不匹配") + observations = {} + source_image_ids = source_image_ids or set() + user_evidence = user_evidence or {} + for index, row in enumerate(graph.get("observations") or []): + key = row.get("id") + if not key or key in observations or row.get("source_type") not in {"image", "user"}: + issue("evidence_identity", f"/observations/{index}", "证据ID缺失、重复或来源不明确") + continue + if row.get("source_type") == "image" and row.get("image_id") not in source_image_ids: + issue("evidence_image", f"/observations/{index}", "图像证据未引用本轮实际提供的原图") + continue + if row.get("source_type") == "user" and user_evidence.get(key) != row: + issue("evidence_user", f"/observations/{index}", "用户确认必须来自会话事实库,不能由模型自行生成") + continue + observations[key] = row + resolver = ParameterResolver(graph.get("parameters") or {}, observations) + for key in resolver.parameters: + try: + resolver.parameter(key) + except (GraphError, TypeError, AttributeError) as exc: + issue("parameter", f"/parameters/{key}", str(exc)) + envelope = [] + try: + envelope = [resolver.length(value) for value in graph["envelope_mm"]] + if len(envelope) != 3 or min(envelope) <= 0: + raise GraphError("整体包围尺寸必须有三个正值") + except (GraphError, KeyError, TypeError) as exc: + issue("envelope", "/envelope_mm", str(exc)) + parts = {} + panel_roles = { + "top", "bottom", "side", "shelf", "door", "back", "drawer_front", + "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", + "drawer_support", "divider", + } + allowed_roles = panel_roles | {"leg", "rail", "stile", "frame_member", "hardware", "other"} + allowed_material_families = { + "engineered_panel", "solid_wood", "metal", "plastic", "glass", "other", + } + for index, part in enumerate(graph.get("parts") or []): + key = part.get("id") + if not key or key in parts: + issue("part_identity", f"/parts/{index}", "零件ID缺失或重复") + continue + try: + size = [resolver.length(value) for value in part["size_mm"]] + if len(size) != 3 or min(size) <= 0: + raise GraphError("零件三轴尺寸必须大于0") + if part.get("shape") != "box": + raise GraphError("当前编译器不支持此几何,不得默默替换成长方体") + role = part.get("functional_role") + if role not in allowed_roles: + raise GraphError("零件必须声明稳定的functional_role,不能由中文名称反推结构") + material_family = part.get("material_family") + if material_family not in {None, "", *allowed_material_families}: + raise GraphError("零件material_family不在受支持范围") + thickness_axis = part.get("thickness_axis") + if role in panel_roles: + if thickness_axis not in {"x", "y", "z"}: + raise GraphError("板件必须明确本地厚度轴thickness_axis") + thickness_index = "xyz".index(thickness_axis) + if size[thickness_index] > min(size) + 0.6: + raise GraphError("声明的厚度轴不是此板件最薄轴") + elif thickness_axis not in {None, "", "x", "y", "z"}: + raise GraphError("非板件厚度轴只能为空或x/y/z") + parts[key] = {**part, "size_mm": size} + except (GraphError, KeyError, TypeError) as exc: + issue("part_geometry", f"/parts/{index}", str(exc)) + assemblies = {} + assembly_bounds = {} + if graph.get("assemblies") is not None: + for index, assembly in enumerate(graph.get("assemblies") or []): + path = f"/assemblies/{index}" + key = assembly.get("id") if isinstance(assembly, dict) else None + if not key or key in assemblies: + issue("assembly_identity", path, "总成ID缺失或重复") + continue + if assembly.get("role") not in {"carcass", "drawer", "door", "module", "frame", "hardware_group", "other"}: + issue("assembly_role", path, "总成必须声明可审计的结构角色") + continue + refs = assembly.get("evidence_ids") or [] + if not refs or any(ref not in observations for ref in refs): + issue("assembly_evidence", path, "总成划分必须引用本轮原图或真实用户证据") + continue + try: + raw_bounds = assembly["bounds_mm"] + if not isinstance(raw_bounds, dict) or set(raw_bounds) != {"x", "y", "z"}: + raise GraphError("总成边界必须分别声明X/Y/Z最小与最大值") + bounds = [] + for axis, axis_name in enumerate("xyz"): + values = [resolver.length(value) for value in raw_bounds[axis_name]] + if len(values) != 2 or values[1] <= values[0]: + raise GraphError(f"总成{axis_name.upper()}轴上限必须大于下限") + if envelope and (values[0] < -0.5 or values[1] > envelope[axis] + 0.5): + raise GraphError(f"总成{axis_name.upper()}轴边界超出产品外形") + bounds.append(values) + assembly_bounds[key] = bounds + assemblies[key] = {**assembly, "bounds_mm": { + axis_name: bounds[axis] for axis, axis_name in enumerate("xyz") + }} + except (GraphError, KeyError, TypeError) as exc: + issue("assembly_bounds", path, str(exc)) + instances = {} + instance_specs = {} + for index, inst in enumerate(graph.get("instances") or []): + key = inst.get("id") + try: + if not key or key in instances or key in instance_specs or inst.get("part_id") not in parts or not inst.get("assembly_id"): + raise GraphError("实例ID重复、零件不存在或未声明所属总成") + # Axis order is explicit. No board-name/longest-edge remapping. + if inst.get("axis_order", [0, 1, 2]) not in ([0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]): + raise GraphError("只支持明确的正交轴映射,任意角度需后续几何编译器") + size = [parts[inst["part_id"]]["size_mm"][a] for a in inst.get("axis_order", [0, 1, 2])] + local_thickness_axis = parts[inst["part_id"]].get("thickness_axis") + product_thickness_axis = None + if local_thickness_axis in {"x", "y", "z"}: + product_thickness_axis = "xyz"[ + inst.get("axis_order", [0, 1, 2]).index("xyz".index(local_thickness_axis)) + ] + inherited = { + "functional_role": parts[inst["part_id"]].get("functional_role"), + "thickness_axis": product_thickness_axis, + "material_family": parts[inst["part_id"]].get("material_family"), + } + if graph.get("placement_solver") == "anchored_axes_v1": + instance_specs[key] = {**inst, **inherited, "size_mm": size} + else: + origin = [resolver.length(value) for value in inst["position_mm"]] + if len(origin) != 3: + raise GraphError("实例位置必须有三轴") + instances[key] = {**inst, **inherited, "position_mm": origin, "size_mm": size} + except (GraphError, KeyError, TypeError) as exc: + issue("instance_geometry", f"/instances/{index}", str(exc)) + placement_audit = [] + if graph.get("placement_solver") == "anchored_axes_v1" and envelope: + try: + instances, placement_audit = _solve_anchored_axis_placements( + instance_specs, resolver=resolver, envelope_mm=envelope, + assembly_bounds=assembly_bounds, + ) + except (GraphError, KeyError, TypeError) as exc: + issue("placement_constraint", "/instances", str(exc)) + if graph.get("assemblies") is not None: + for key, inst in instances.items(): + if inst.get("assembly_id") not in assemblies: + issue("instance_assembly", f"/instances/{key}/assembly_id", "实例引用了未声明的总成") + continue + bounds = assembly_bounds[inst["assembly_id"]] + outside_axes = [] + for axis, axis_name in enumerate("xyz"): + low = inst["position_mm"][axis] + high = low + inst["size_mm"][axis] + if low < bounds[axis][0] - 0.5 or high > bounds[axis][1] + 0.5: + outside_axes.append({ + "axis": axis_name, "instance_range_mm": [low, high], + "assembly_range_mm": bounds[axis], + }) + if outside_axes: + issue( + "outside_assembly_bounds", f"/instances/{key}", + f"部件超出所属总成{inst['assembly_id']}的局部边界;不能借用整件产品坐标掩盖模块错位", + assembly_id=inst["assembly_id"], axes=outside_axes, + ) + used = Counter(inst.get("assembly_id") for inst in instances.values()) + for key in assemblies: + if not used[key]: + issue("empty_assembly", f"/assemblies/{key}", "空总成不能作为装配关系的虚拟占位") + continue + rows = [inst for inst in instances.values() if inst.get("assembly_id") == key] + if not rows: + continue + actual = [ + [min(row["position_mm"][axis] for row in rows), + max(row["position_mm"][axis] + row["size_mm"][axis] for row in rows)] + for axis in range(3) + ] + mismatch = [] + for axis, axis_name in enumerate("xyz"): + declared = assembly_bounds[key][axis] + if abs(actual[axis][0] - declared[0]) > 0.6 or abs(actual[axis][1] - declared[1]) > 0.6: + mismatch.append({ + "axis": axis_name, "declared_mm": declared, + "actual_member_union_mm": actual[axis], + }) + if mismatch: + issue( + "assembly_bounds_not_tight", f"/assemblies/{key}/bounds_mm", + "总成边界必须等于所属实体的实际包围盒,不能用过大的虚拟空间掩盖错位或漏件", + assembly_id=key, axes=mismatch, + ) + solver_adjustments = [] + if graph.get("constraint_solver") == "orthogonal_contacts_v1": + solver_adjustments, solver_errors = _solve_orthogonal_contact_normals( + instances, list(graph.get("connections") or []) + ) + issues.extend(solver_errors) + quantities = Counter(inst["part_id"] for inst in instances.values()) + for key, part in parts.items(): + if not quantities[key]: + issue("uninstantiated_part", f"/parts/{key}", "零件没有物理实例,不能计入BOM") + expected = part.get("observed_quantity") + if expected is not None and expected != quantities[key]: + issue("quantity_conflict", f"/parts/{key}", f"图像数量{expected}与实例数量{quantities[key]}不符") + role_quantities = Counter( + str(inst.get("functional_role") or "") + for inst in instances.values() + if str(inst.get("functional_role") or "") + ) + role_contract = (topology_contract or {}).get("role_counts") or {} + if isinstance(role_contract, dict): + for role, expected in role_contract.items(): + if ( + not isinstance(role, str) + or isinstance(expected, bool) + or not isinstance(expected, int) + or expected < 0 + ): + issue("source_topology_contract", "/topology_contract/role_counts", "原图拓扑契约数量格式无效") + continue + actual = role_quantities.get(role, 0) + if actual != expected: + issue( + "source_topology_count_conflict", + f"/topology_contract/role_counts/{role}", + f"独立多视图证据计数{role}={expected},结构图谱生成{actual};禁止静默漏件或多件", + role=role, + expected=expected, + actual=actual, + evidence=((topology_contract or {}).get("evidence") or {}).get(role) or [], + ) + seen_connections = set() + neighbours = {key: set() for key in instances} + for index, edge in enumerate(graph.get("connections") or []): + path = f"/connections/{index}" + key = edge.get("id") + if not key or key in seen_connections: + issue("connection_identity", path, "连接ID缺失或重复") + continue + seen_connections.add(key) + a, b = instances.get(edge.get("a")), instances.get(edge.get("b")) + if not a or not b or a["id"] == b["id"]: + issue("connection_target", path, "连接两端必须是不同且存在的物理实例") + continue + if edge.get("kind") not in {"fixed", "sliding", "hinged", "support"}: + issue("connection_kind", path, "连接方式不明确") + continue + neighbours[a["id"]].add(b["id"]) + neighbours[b["id"]].add(a["id"]) + if edge["kind"] not in {"fixed", "support"}: + continue # Motion/hardware templates require a later dedicated validator. + faces = [edge.get("face_a"), edge.get("face_b")] + if any(face not in {"x-", "x+", "y-", "y+", "z-", "z+"} for face in faces): + issue("connection_face", path, "固定连接必须指定两个接触面") + continue + if faces[0][0] != faces[1][0] or faces[0][1] == faces[1][1]: + issue("connection_face", path, "接触面法向必须相反") + continue + axis = "xyz".index(faces[0][0]) + plane_a = a["position_mm"][axis] + (a["size_mm"][axis] if faces[0][1] == "+" else 0) + plane_b = b["position_mm"][axis] + (b["size_mm"][axis] if faces[1][1] == "+" else 0) + tolerance = float(edge.get("tolerance_mm", 0.5)) + if not math.isfinite(tolerance) or not 0 <= tolerance <= 2: + issue("connection_tolerance", path, "自动几何接触检查公差必须为0–2mm,不能用大公差掩盖错位") + continue + overlap = [min(a["position_mm"][d] + a["size_mm"][d], b["position_mm"][d] + b["size_mm"][d]) - + max(a["position_mm"][d], b["position_mm"][d]) for d in range(3) if d != axis] + if abs(plane_a - plane_b) > tolerance or min(overlap) <= 0: + required_b_origin = plane_a - (b["size_mm"][axis] if faces[1][1] == "+" else 0.0) + repair_hint = ( + "两平面已重合但切向没有正面积交叠;优先回看板件是端面对侧面还是上下叠放,并修改face_a/face_b,不能移动到产品外" + if abs(plane_a - plane_b) <= tolerance and min(overlap) <= 0 + else "接触法向坐标错误;核对连接面后再修改实例位置或约束" + ) + issue( + "connection_gap", path, + f"{a['id']}的{faces[0]}与{b['id']}的{faces[1]}未接触;法向间距{abs(plane_a-plane_b):.3f}mm,切向重叠{overlap}mm", + connection_id=key, a=a["id"], b=b["id"], face_a=faces[0], face_b=faces[1], + axis="xyz"[axis], plane_a_mm=plane_a, plane_b_mm=plane_b, + current_b_origin_mm=b["position_mm"][axis], required_b_origin_mm=required_b_origin, + suggested_repair=repair_hint, + ) + derived_contacts = [] + if graph.get("contact_derivation") == "aabb_within_assembly_v1": + derived_contacts = _derive_aabb_contacts(instances) + for edge in derived_contacts: + neighbours[edge["a"]].add(edge["b"]) + neighbours[edge["b"]].add(edge["a"]) + assembly_connection_audit = [] + if assemblies: + members = { + assembly_id: [inst for inst in instances.values() if inst.get("assembly_id") == assembly_id] + for assembly_id in assemblies + } + # A relation between assemblies cannot hide loose parts inside either + # assembly. Every multi-part assembly must first be a real contact + # component on its own. + internal_neighbours = {key: set() for key in instances} + for edge in derived_contacts: + internal_neighbours[edge["a"]].add(edge["b"]) + internal_neighbours[edge["b"]].add(edge["a"]) + for assembly_id, rows in members.items(): + if len(rows) <= 1: + continue + member_ids = {row["id"] for row in rows} + reached, pending = set(), [rows[0]["id"]] + while pending: + current = pending.pop() + if current in reached: + continue + reached.add(current) + pending.extend((internal_neighbours[current] & member_ids) - reached) + for instance_id in sorted(member_ids - reached): + issue( + "assembly_internal_disconnected", f"/instances/{instance_id}", + f"部件在总成{assembly_id}内没有真实接触路径,总成运动副不能掩盖散落零件", + assembly_id=assembly_id, + ) + seen_assembly_connections = set() + assembly_neighbours = {key: set() for key in assemblies} + for index, edge in enumerate(graph.get("assembly_connections") or []): + path = f"/assembly_connections/{index}" + key = edge.get("id") if isinstance(edge, dict) else None + if not key or key in seen_assembly_connections: + issue("assembly_connection_identity", path, "总成连接ID缺失或重复") + continue + seen_assembly_connections.add(key) + a_id, b_id = edge.get("a"), edge.get("b") + instance_endpoints = { + endpoint: instances[endpoint].get("assembly_id") + for endpoint in (a_id, b_id) if endpoint in instances + } + if instance_endpoints: + moving_hint = None + if a_id in instances: + current_assembly = instances[a_id].get("assembly_id") + moving_hint = { + "instance_id": a_id, + "current_assembly_id": current_assembly, + "required_action": ( + "若该实例是运动件,为它建立边界等于实体包围盒的独立总成," + "把实例assembly_id迁入新总成,并让连接a引用新总成ID" + ), + } + issue( + "assembly_connection_uses_instance", path, + "总成连接错误地引用了零件实例;运动副端点必须是总成ID", + instance_endpoint_to_current_assembly=instance_endpoints, + moving_endpoint_repair=moving_hint, + stationary_endpoint_repair=( + f"连接b应引用固定端实例所属总成{instances[b_id].get('assembly_id')}" + if b_id in instances else None + ), + ) + continue + if a_id not in assemblies or b_id not in assemblies or a_id == b_id: + issue("assembly_connection_target", path, "总成连接两端必须是不同且已声明的总成") + continue + if not members.get(a_id) or not members.get(b_id): + issue("assembly_connection_target", path, "总成连接不能引用空总成") + continue + kind = edge.get("kind") + if kind not in {"fixed", "sliding", "hinged"}: + issue("assembly_connection_kind", path, "总成关系只能是fixed/sliding/hinged") + continue + refs = edge.get("evidence_ids") or [] + if not refs or any(ref not in observations for ref in refs): + issue("assembly_connection_evidence", path, "总成关系必须引用真实证据") + continue + contact = None + if kind == "fixed": + for a_inst in members[a_id]: + for b_inst in members[b_id]: + contact = _aabb_contact(a_inst, b_inst) + if contact: + break + if contact: + break + if not contact: + issue( + "fixed_assemblies_not_contacting", path, + f"固定总成{a_id}与{b_id}之间没有任何真实正面积接触", + ) + continue + else: + if edge.get("axis") not in {"x", "y", "z"}: + issue("assembly_motion_axis", path, "滑动/铰接总成必须明确声明运动轴") + continue + if kind == "sliding": + limits = edge.get("travel_limits_mm") + try: + values = [resolver.length(value) for value in limits] + if len(values) != 2 or values[1] <= values[0]: + raise GraphError("滑动行程上限必须大于下限") + except (GraphError, TypeError) as exc: + issue("assembly_motion_limits", path, f"滑动总成需要两个有效行程限位: {exc}") + continue + else: + limits = edge.get("angle_limits_deg") + if ( + not isinstance(limits, list) or len(limits) != 2 + or any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in limits) + or limits[1] <= limits[0] + ): + issue("assembly_motion_limits", path, "铰接总成需要两个有效角度限位") + continue + if kind == "sliding": + motion_axis = "xyz".index(edge["axis"]) + moving_bounds = assembly_bounds[a_id] + guide_bounds = assembly_bounds[b_id] + clearance_errors = [] + for axis, axis_name in enumerate("xyz"): + if axis == motion_axis: + continue + if ( + moving_bounds[axis][0] < guide_bounds[axis][0] - 0.5 + or moving_bounds[axis][1] > guide_bounds[axis][1] + 0.5 + ): + clearance_errors.append({ + "axis": axis_name, + "moving_range_mm": moving_bounds[axis], + "guide_range_mm": guide_bounds[axis], + }) + if clearance_errors: + issue( + "sliding_assembly_outside_guide", path, + f"滑动总成{a_id}在非运动轴上超出导向总成{b_id},无法形成科学的抽拉关系", + axes=clearance_errors, + ) + continue + a_rep, b_rep = members[a_id][0], members[b_id][0] + neighbours[a_rep["id"]].add(b_rep["id"]) + neighbours[b_rep["id"]].add(a_rep["id"]) + assembly_neighbours[a_id].add(b_id) + assembly_neighbours[b_id].add(a_id) + assembly_connection_audit.append({ + "id": key, "kind": kind, "a": a_id, "b": b_id, + "axis": edge.get("axis"), "verified_contact": contact, + }) + if len(assemblies) > 1: + reached, pending = set(), [next(iter(assemblies))] + while pending: + key = pending.pop() + if key not in reached: + reached.add(key) + pending.extend(assembly_neighbours[key] - reached) + for key in assemblies.keys() - reached: + issue("disconnected_assembly", f"/assemblies/{key}", "此总成没有连接到产品的装配关系图") + if len(instances) > 1: + reached, pending = set(), [next(iter(instances))] + while pending: + key = pending.pop() + if key not in reached: + reached.add(key) + pending.extend(neighbours[key] - reached) + for key in instances.keys() - reached: + issue("disconnected_instance", f"/instances/{key}", "此部件没有可追溯到产品整体的连接路径;不能靠省略连接关系跳过几何检查") + seen_boxes = {} + collision_pairs = [] + instance_rows = list(instances.values()) + for left_index, a in enumerate(instance_rows): + for b in instance_rows[left_index + 1:]: + collision = _aabb_interference(a, b) + if collision: + collision_pairs.append(collision) + issue( + "solid_interference", f"/instances/{b['id']}", + f"实体{a['id']}与{b['id']}发生正体积穿插,不能作为可制造装配", + **collision, + ) + for key, inst in instances.items(): + signature = tuple(round(value, 6) for value in inst["position_mm"] + inst["size_mm"]) + if signature in seen_boxes: + issue("duplicate_solid", f"/instances/{key}", f"与{seen_boxes[signature]}占据同一实体,禁止重复计费") + seen_boxes[signature] = key + if envelope: + for key, inst in instances.items(): + if any(inst["position_mm"][axis] < -0.5 or + inst["position_mm"][axis] + inst["size_mm"][axis] > envelope[axis] + 0.5 for axis in range(3)): + issue("outside_envelope", f"/instances/{key}", "部件超出产品边界;不允许自动裁小来隐藏冲突") + # Directly read overall arrows are external evidence, not an AI naming + # convention. Compare the compiled X/Y/Z envelope to that ledger so a + # model cannot silently turn a 300 mm side-view depth into product width. + # Conflicted or low-confidence labels are intentionally omitted by the + # upstream ledger builder and therefore remain AI-estimated draft values. + if len(envelope) == 3: + for axis_index, axis in enumerate("xyz"): + fact = (dimension_ledger or {}).get(axis) + if not isinstance(fact, dict) or not isinstance(fact.get("value_mm"), (int, float)): + continue + expected = float(fact["value_mm"]) + tolerance = max(1.0, float(fact.get("tolerance_mm") or 1.0)) + if abs(envelope[axis_index] - expected) > tolerance: + issue( + "overall_dimension_conflict", + f"/envelope_mm/{axis_index}", + f"CAD整体{axis.upper()}轴{envelope[axis_index]:.3f}mm与原图尺寸箭头{expected:.3f}mm冲突", + axis=axis, + compiled_mm=envelope[axis_index], + source_mm=expected, + source_image_ids=list(fact.get("source_image_ids") or []), + ) + return { + "schema_version": SCHEMA_VERSION, + "graph_sha256": hashlib.sha256(json.dumps(graph, ensure_ascii=False, sort_keys=True).encode()).hexdigest(), + "ok": not issues, "issues": issues, "release_status": "drawing_draft", + "visual_validation_required": True, "manufacturing_validation_required": True, + "parameters": {key: {"value": value, "length_exponent": dimension, "confidence": confidence, "estimated": estimated} + for key, (value, dimension, confidence, estimated) in resolver.resolved.items()}, + "instances": list(instances.values()), "solver_adjustments": solver_adjustments, + "placement_audit": placement_audit, + "derived_contacts": derived_contacts, + "assemblies": list(assemblies.values()), + "assembly_connection_audit": assembly_connection_audit, + "collision_audit": collision_pairs, + "dimension_ledger": dimension_ledger or {}, + "topology_contract": topology_contract or {}, + "bom": [{"part_id": key, "name": part.get("name"), "size_mm": part["size_mm"], + "quantity": quantities[key], "functional_role": part.get("functional_role"), + "material_family": part.get("material_family")} + for key, part in parts.items() if quantities[key]], + } diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py new file mode 100644 index 0000000..85ec9c6 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_manufacturing_oracle.py @@ -0,0 +1,205 @@ +"""General manufacturing-feature tests; no SKU/name answer constants.""" +import ast +import copy +import pathlib +import tempfile +import unittest + +from manufacturing_oracle import plan_manufacturing_features +from probe_structure_graph import ( + cad_source, source_for_role, structured_kernel_feedback, + structured_patch_feedback, +) +from structure_graph import SCHEMA_VERSION, compile_graph + + +def observed(value): + return {"kind": "length", "origin": "observed", "value": value, "unit": "mm", + "evidence_ids": ["view"], "confidence": 0.95} + + +def graph_for_role(role="shelf", material="engineered_panel", z="h-t"): + return { + "schema_version": SCHEMA_VERSION, + "placement_solver": "anchored_axes_v1", + "contact_derivation": "aabb_within_assembly_v1", + "observations": [{"id": "view", "source_type": "image", "image_id": "source", "confidence": 0.95}], + "parameters": { + "w": observed(800), "d": observed(400), "h": observed(1100), "t": observed(18), + "inner": {"kind": "length", "origin": "derived", "expression": "w-2*t"}, + }, + "envelope_mm": ["w", "d", "h"], + "parts": [ + {"id": "side", "name": "显示名称随意", "shape": "box", "functional_role": "side", + "thickness_axis": "x", "material_family": material, "size_mm": ["t", "d", "h"], "observed_quantity": 2}, + {"id": "span", "name": "另一个任意名字", "shape": "box", "functional_role": role, + "thickness_axis": "z", "material_family": material, "size_mm": ["inner", "d", "t"], "observed_quantity": 1}, + ], + "instances": [ + {"id": "left", "part_id": "side", "assembly_id": "root", "placement": { + "x": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}}}, + {"id": "right", "part_id": "side", "assembly_id": "root", "placement": { + "x": {"mode": "align", "self_anchor": "max", "target": "envelope", "target_anchor": "max"}, + "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}}}, + {"id": "span", "part_id": "span", "assembly_id": "root", "placement": { + "x": {"mode": "align", "self_anchor": "min", "target": "left", "target_anchor": "max"}, + "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "z": {"mode": "absolute", "value_mm": z}}}, + ], + "connections": [], + "uncertainties": [], + } + + +def compile_case(graph): + result = compile_graph(graph, source_image_ids={"source"}) + assert result["ok"], result["issues"] + return result + + +class ManufacturingOracleTest(unittest.TestCase): + def test_probe_resolves_semantic_role_filename_aliases(self): + with tempfile.TemporaryDirectory() as folder: + root = pathlib.Path(folder) + expected = { + "front": root / "sample-front.webp", + "side": root / "sample-side.jpeg", + "third": root / "sample-open.png", + "dimension": root / "sample-dimensions.jpg", + } + for path in expected.values(): + path.touch() + for role, path in expected.items(): + self.assertEqual(path, source_for_role(root, "sample", role)) + + def test_shelf_pins_come_from_two_real_contacts_and_are_counted_once(self): + compiled = compile_case(graph_for_role()) + plan = plan_manufacturing_features(compiled) + self.assertTrue(plan["ok"], plan["issues"]) + self.assertEqual(4, plan["cost_drivers"]["hole_operations"]) + self.assertEqual(4, plan["cost_drivers"]["hardware_instances"]) + self.assertEqual(4, plan["hardware_lines"][0]["quantity"]) + self.assertFalse(plan["features_by_instance"]["span"]["holes"]) + self.assertTrue(all(row["validation_status"] == "valid" for side in ("left", "right") + for row in plan["features_by_instance"][side]["holes"])) + + def test_confirmat_pairs_are_coaxial_even_when_contact_faces_are_reversed(self): + compiled = compile_case(graph_for_role(role="top")) + plan = plan_manufacturing_features(compiled) + self.assertTrue(plan["ok"], plan["issues"]) + self.assertEqual(6, plan["cost_drivers"]["hardware_instances"]) + self.assertEqual(12, plan["cost_drivers"]["hole_operations"]) + paired = {} + for rows in plan["features_by_instance"].values(): + for hole in rows["holes"]: + paired.setdefault(hole["hardware_instance_id"], []).append(hole) + self.assertEqual({2}, {len(rows) for rows in paired.values()}) + for rows in paired.values(): + axis = rows[0]["axis"] + tangents = [name for name in "xyz" if name != axis] + self.assertEqual( + tuple(rows[0]["world_center_mm"][name] for name in tangents), + tuple(rows[1]["world_center_mm"][name] for name in tangents), + ) + + def test_captured_panel_creates_groove_not_connector_hardware(self): + compiled = compile_case(graph_for_role(role="drawer_bottom", z=100)) + plan = plan_manufacturing_features(compiled) + self.assertTrue(plan["ok"], plan["issues"]) + self.assertEqual(2, plan["cost_drivers"]["groove_operations"]) + self.assertEqual(0, plan["cost_drivers"]["hardware_instances"]) + self.assertEqual({"left", "right"}, { + instance_id for instance_id, rows in plan["features_by_instance"].items() if rows["grooves"] + }) + original = next(row for row in compiled["instances"] if row["id"] == "span") + manufactured = plan["manufacturing_geometry_by_instance"]["span"] + self.assertEqual(original["position_mm"][0] - 6.0, manufactured["position_mm"][0]) + self.assertEqual(original["size_mm"][0] + 12.0, manufactured["size_mm"][0]) + bottom_bom = next(row for row in plan["manufacturing_bom"] if row["part_id"] == "span") + self.assertEqual([original["size_mm"][0] + 12.0, 400.0, 18.0], bottom_bom["manufacturing_size_mm"]) + source = cad_source(compiled, plan) + self.assertIn(f"Box({original['size_mm'][0] + 12.0!r}, 400.0, 18.0", source) + + def test_captured_panel_broad_face_contact_is_blocked_not_invented(self): + compiled = compile_case(graph_for_role(role="drawer_bottom", z=100)) + contact = compiled["derived_contacts"][0] + contact["interface_type"] = "face_to_face" + plan = plan_manufacturing_features(compiled) + self.assertFalse(plan["ok"]) + self.assertIn("captured_interface_incompatible", {row["code"] for row in plan["issues"]}) + + def test_engineered_panel_mortise_is_blocked_by_material_not_display_name(self): + compiled = compile_case(graph_for_role(role="top")) + plan = plan_manufacturing_features(compiled, policy={"engineered_panel_joint": "mortise_tenon"}) + self.assertFalse(plan["ok"]) + self.assertEqual({"material_process_incompatible"}, {row["code"] for row in plan["issues"]}) + + def test_metal_contact_generates_weld_length_without_fake_holes(self): + compiled = compile_case(graph_for_role(role="top", material="metal")) + plan = plan_manufacturing_features(compiled) + self.assertTrue(plan["ok"], plan["issues"]) + self.assertEqual(0, plan["cost_drivers"]["hole_operations"]) + self.assertGreater(plan["cost_drivers"]["weld_length_m"], 0) + + def test_names_and_parameter_changes_do_not_change_joint_semantics(self): + graph = graph_for_role(role="top") + first = plan_manufacturing_features(compile_case(graph)) + graph["parts"][0]["name"] = "抽屉底板假名字" + graph["parts"][1]["name"] = "柜门铰链假名字" + graph["parameters"]["w"]["value"] = 980 + second = plan_manufacturing_features(compile_case(graph)) + self.assertTrue(second["ok"], second["issues"]) + self.assertEqual( + [row["method"] for row in first["connection_plans"]], + [row["method"] for row in second["connection_plans"]], + ) + self.assertEqual(first["cost_drivers"]["hardware_instances"], second["cost_drivers"]["hardware_instances"]) + + def test_invalid_part_material_is_rejected_before_feature_planning(self): + graph = graph_for_role(material="mystery") + result = compile_graph(graph, source_image_ids={"source"}) + self.assertFalse(result["ok"]) + self.assertIn("part_geometry", {row["code"] for row in result["issues"]}) + + def test_probe_cad_source_physically_subtracts_planned_holes(self): + compiled = compile_case(graph_for_role(role="top")) + plan = plan_manufacturing_features(compiled) + source = cad_source(compiled, plan) + ast.parse(source) + self.assertIn("align=(Align.CENTER, Align.CENTER, Align.CENTER)", source) + self.assertEqual(plan["cost_drivers"]["hole_operations"], source.count("part = part - hole_")) + self.assertNotIn("requested_center_mm", source) + + def test_brep_interference_becomes_instance_scoped_repair_feedback(self): + error = RuntimeError( + '{"ok":false,"clashes":[{"a":{"name":"left-side"},' + '"b":{"name":"left-door"},"volume":2400,' + '"bounds":{"min":[0,10,20],"max":[12,30,50]}}],"errors":[]}' + ) + feedback = structured_kernel_feedback(error) + self.assertEqual(1, len(feedback)) + self.assertEqual("brep_interference", feedback[0]["code"]) + self.assertEqual(["left-side", "left-door"], feedback[0]["instance_ids"]) + self.assertEqual({"x": 12.0, "y": 20.0, "z": 30.0}, feedback[0]["overlap_size_mm"]) + self.assertIn("/instances/left-door/placement", feedback[0]["legal_patch_roots"]) + + def test_rejected_patch_feedback_uses_stable_id_and_existing_axis_shape(self): + graph = graph_for_role() + bad_patch = [{ + "op": "replace", "path": "/instances/2/placement/x/value_mm", "value": 20, + }] + feedback = structured_patch_feedback(graph, bad_patch, RuntimeError("leaf missing")) + self.assertEqual("patch_application_error", feedback[0]["code"]) + self.assertEqual("span", feedback[0]["instance_id"]) + self.assertEqual("align", feedback[0]["current_constraint"]["mode"]) + examples = feedback[0]["legal_repair_examples"] + self.assertEqual("/instances/span/placement/x", examples[0]["path"]) + self.assertEqual("/instances/span/placement/x/offset_mm", examples[1]["path"]) + self.assertEqual("add", examples[1]["op"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/test_structure_graph.py b/server_patch/ai_3d_model/ai-solid-wood-cost/test_structure_graph.py new file mode 100644 index 0000000..c079ea9 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/test_structure_graph.py @@ -0,0 +1,481 @@ +"""Metamorphic assembly tests. No SKU or company-BOM answer constants.""" +import copy +import random +import unittest + +from structure_graph import ( + GraphError, ParameterResolver, SCHEMA_VERSION, apply_graph_patch, + build_dimension_ledger, build_source_topology_contract, compile_graph, +) + + +def param(value, unit="mm"): + return {"kind": "length", "origin": "observed", "value": value, "unit": unit, + "evidence_ids": ["view"], "confidence": 0.95} + + +def frame(width, depth, height, thickness): + return { + "schema_version": SCHEMA_VERSION, + "uncertainties": [], + "observations": [{"id": "view", "source_type": "image", "image_id": "original-front", "confidence": 0.95}], + "parameters": {"w": param(width), "d": param(depth), "h": param(height), "t": param(thickness), + "inner": {"kind": "length", "origin": "derived", "expression": "w - 2*t"}}, + "envelope_mm": ["w", "d", "h"], + "parts": [{"id": "A", "name": "任意名称A", "shape": "box", "functional_role": "side", "thickness_axis": "x", "size_mm": ["t", "d", "h"], "observed_quantity": 2}, + {"id": "B", "name": "任意名称B", "shape": "box", "functional_role": "shelf", "thickness_axis": "z", "size_mm": ["inner", "d", "t"], "observed_quantity": 1}], + "instances": [{"id": "i1", "part_id": "A", "assembly_id": "root", "position_mm": [0, 0, 0]}, + {"id": "i2", "part_id": "A", "assembly_id": "root", "position_mm": ["w-t", 0, 0]}, + {"id": "i3", "part_id": "B", "assembly_id": "root", "position_mm": ["t", 0, "h-t"]}], + "connections": [{"id": "join1", "kind": "fixed", "a": "i1", "b": "i3", "face_a": "x+", "face_b": "x-"}, + {"id": "join2", "kind": "fixed", "a": "i3", "b": "i2", "face_a": "x+", "face_b": "x-"}], + } + + +def compile_case(graph): + return compile_graph(graph, source_image_ids={"original-front"}) + + +def anchored_frame(width=800, depth=400, height=1100, thickness=18): + graph = frame(width, depth, height, thickness) + graph["placement_solver"] = "anchored_axes_v1" + graph["contact_derivation"] = "aabb_within_assembly_v1" + graph["connections"] = [] + placements = { + "i1": { + "x": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + }, + "i2": { + "x": {"mode": "align", "self_anchor": "max", "target": "envelope", "target_anchor": "max"}, + "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "z": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + }, + "i3": { + "x": {"mode": "align", "self_anchor": "min", "target": "i1", "target_anchor": "max"}, + "y": {"mode": "align", "self_anchor": "min", "target": "envelope", "target_anchor": "min"}, + "z": {"mode": "align", "self_anchor": "max", "target": "envelope", "target_anchor": "max"}, + }, + } + for instance in graph["instances"]: + instance.pop("position_mm") + instance["placement"] = placements[instance["id"]] + return graph + + +def anchored_frame_with_drawer_assembly(): + graph = anchored_frame() + graph["assemblies"] = [ + {"id": "root", "role": "carcass", "evidence_ids": ["view"], + "bounds_mm": {"x": [0, "w"], "y": [0, "d"], "z": [0, "h"]}}, + {"id": "drawer-1", "role": "drawer", "evidence_ids": ["view"], + "bounds_mm": {"x": ["t+10", "w-t-10"], "y": [20, 320], "z": [500, 600]}}, + ] + graph["parts"].append({ + "id": "D", "name": "独立运动总成代表体", "shape": "box", + "functional_role": "other", "thickness_axis": None, + "size_mm": ["inner - 20", 300, 100], "observed_quantity": 1, "evidence_ids": ["view"], + }) + graph["instances"].append({ + "id": "drawer-solid", "part_id": "D", "assembly_id": "drawer-1", + "axis_order": [0, 1, 2], + "placement": { + "x": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"}, + "y": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"}, + "z": {"mode": "align", "self_anchor": "min", "target": "assembly", "target_anchor": "min"}, + }, + }) + graph["assembly_connections"] = [{ + "id": "drawer-slide", "kind": "sliding", "a": "drawer-1", "b": "root", + "axis": "y", "travel_limits_mm": [0, 250], "evidence_ids": ["view"], + }] + return graph + + +class StructureGraphTest(unittest.TestCase): + def test_independent_multiview_role_count_blocks_silent_graph_omission(self): + graph = anchored_frame() + source_structure = { + "parts": [ + {"source_structure_id": "S1", "component_role": "side", "quantity": 2, + "confidence": 0.96, "image_ids": [1, 2]}, + {"source_structure_id": "S2", "component_role": "shelf", "quantity": 2, + "confidence": 0.91, "image_ids": [1, 3]}, + {"source_structure_id": "S3", "component_role": "door", "quantity": 5, + "confidence": 0.51, "image_ids": [1]}, + ] + } + contract = build_source_topology_contract(source_structure) + self.assertEqual({"shelf": 2, "side": 2}, contract["role_counts"]) + result = compile_graph( + graph, source_image_ids={"original-front"}, topology_contract=contract + ) + self.assertFalse(result["ok"]) + mismatch = next( + row for row in result["issues"] + if row["code"] == "source_topology_count_conflict" + ) + self.assertEqual( + ("shelf", 2, 1), + (mismatch["role"], mismatch["expected"], mismatch["actual"]), + ) + + def test_random_dimensions_and_thickness_recompute_all_dependencies(self): + rng = random.Random(80721) + for _ in range(100): + w, d, h, t = rng.randint(360, 2200), rng.randint(180, 800), rng.randint(500, 2200), rng.randint(9, 35) + graph = frame(w, d, h, t) + first = compile_case(graph) + self.assertTrue(first["ok"], first["issues"]) + new_w, new_t = w + rng.randint(5, 180), t + 3 + graph["parameters"]["w"]["value"] = new_w + graph["parameters"]["t"]["value"] = new_t + second = compile_case(graph) + self.assertTrue(second["ok"], second["issues"]) + self.assertEqual(new_w - 2 * new_t, second["bom"][1]["size_mm"][0]) + self.assertEqual([2, 1], [row["quantity"] for row in second["bom"]]) + self.assertNotEqual(first["graph_sha256"], second["graph_sha256"]) + + def test_names_do_not_change_dimensions_role_or_count(self): + graph = frame(840, 420, 1150, 16) + before = compile_case(graph) + graph["parts"][0]["name"] = "抽屉面板中竖板" + graph["parts"][1]["name"] = "抽屉底板封边减板厚" + after = compile_case(graph) + self.assertTrue(after["ok"]) + self.assertEqual(before["instances"], after["instances"]) + + def test_cm_and_mm_evidence_resolve_to_same_geometry(self): + graph = frame(800, 400, 1100, 18) + original = compile_case(graph) + for value in graph["parameters"].values(): + if value.get("origin") == "observed": + value["value"] /= 10 + value["unit"] = "cm" + self.assertEqual(original["instances"], compile_case(graph)["instances"]) + + def test_connection_gap_returned_to_ai_not_silently_clamped(self): + graph = frame(800, 400, 1100, 18) + graph["instances"][2]["position_mm"][0] = "t*2" + result = compile_case(graph) + self.assertFalse(result["ok"]) + self.assertIn("connection_gap", {row["code"] for row in result["issues"]}) + self.assertEqual(36, result["instances"][2]["position_mm"][0]) + + def test_duplicate_solids_and_missing_instances_are_not_hidden(self): + graph = frame(800, 400, 1100, 18) + graph["instances"][1]["position_mm"] = [0, 0, 0] + result = compile_case(graph) + self.assertIn("duplicate_solid", {row["code"] for row in result["issues"]}) + graph["instances"].pop() + self.assertIn("uninstantiated_part", {row["code"] for row in compile_case(graph)["issues"]}) + + def test_dimension_errors_cycles_and_code_execution_are_rejected(self): + graph = frame(800, 400, 1100, 18) + for expression in ("w + 2", "w*w", "inner + t", "__import__('os').system('true')"): + candidate = copy.deepcopy(graph) + candidate["parameters"]["inner"]["expression"] = expression + self.assertFalse(compile_case(candidate)["ok"], expression) + + def test_model_cannot_invent_original_image_or_user_confirmation(self): + graph = frame(800, 400, 1100, 18) + graph["observations"][0]["image_id"] = "invented" + self.assertIn("evidence_image", {row["code"] for row in compile_case(graph)["issues"]}) + graph["observations"][0]["source_type"] = "user" + self.assertIn("evidence_user", {row["code"] for row in compile_case(graph)["issues"]}) + + def test_deleting_connections_cannot_hide_a_floating_part(self): + graph = frame(800, 400, 1100, 18) + graph["connections"] = [] + result = compile_case(graph) + self.assertFalse(result["ok"]) + self.assertIn("disconnected_instance", {row["code"] for row in result["issues"]}) + + def test_geometry_context_accepts_mm_literals_but_derived_parameters_remain_dimension_safe(self): + resolver = ParameterResolver({"w": param(800), "t": param(18)}, + {"view": {"source_type": "image", "confidence": 0.95}}) + self.assertEqual(5, resolver.length("5")) + self.assertEqual(343, resolver.length("(w - 3*t) / 2 - 30")) + with self.assertRaises(GraphError): + resolver.expression("w - 30") + + def test_contact_solver_recomputes_normal_positions_and_keeps_audit(self): + graph = frame(800, 400, 1100, 18) + graph["constraint_solver"] = "orthogonal_contacts_v1" + graph["instances"][1]["position_mm"] = [760, 0, 0] + graph["instances"][2]["position_mm"] = [30, 0, "h-t"] + result = compile_case(graph) + self.assertTrue(result["ok"], result["issues"]) + positions = {row["id"]: row["position_mm"] for row in result["instances"]} + self.assertEqual(782, positions["i2"][0]) + self.assertEqual(18, positions["i3"][0]) + self.assertGreaterEqual(len(result["solver_adjustments"]), 2) + + def test_contact_solver_blocks_contradictory_cycle(self): + graph = frame(800, 400, 1100, 18) + graph["constraint_solver"] = "orthogonal_contacts_v1" + graph["connections"].append({"id": "bad", "kind": "fixed", "a": "i1", "b": "i2", + "face_a": "x-", "face_b": "x+"}) + result = compile_case(graph) + self.assertFalse(result["ok"]) + self.assertIn("constraint_cycle_conflict", {row["code"] for row in result["issues"]}) + + def test_local_graph_patch_is_audited_and_cannot_escape_schema(self): + graph = frame(800, 400, 1100, 18) + updated, audit = apply_graph_patch(graph, [ + {"op": "replace", "path": "/parameters/w/value", "value": 920}, + {"op": "add", "path": "/uncertainties/-", "value": "隐藏连接件待制造模板复核"}, + ]) + self.assertEqual(920, updated["parameters"]["w"]["value"]) + self.assertEqual(800, graph["parameters"]["w"]["value"]) + self.assertEqual(2, len(audit)) + with self.assertRaises(GraphError): + apply_graph_patch(graph, [{"op": "replace", "path": "/schema_version", "value": "evil"}]) + with self.assertRaises(GraphError): + apply_graph_patch(graph, [{"op": "remove", "path": "/parameters/missing"}]) + + def test_local_graph_patch_resolves_stable_ids_in_arrays(self): + graph = anchored_frame_with_drawer_assembly() + updated, audit = apply_graph_patch(graph, [ + {"op": "replace", "path": "/assemblies/drawer-1/bounds_mm/z", "value": [510, 610]}, + {"op": "add", "path": "/instances/drawer-solid/placement/z/offset_mm", "value": 5}, + ]) + self.assertEqual([510, 610], updated["assemblies"][1]["bounds_mm"]["z"]) + self.assertEqual(5, updated["instances"][-1]["placement"]["z"]["offset_mm"]) + self.assertEqual(2, len(audit)) + with self.assertRaises(GraphError): + apply_graph_patch(graph, [ + {"op": "replace", "path": "/assemblies/missing/bounds_mm/z", "value": [0, 1]}, + ]) + + def test_direct_source_dimension_ledger_blocks_axis_swap(self): + graph = frame(800, 400, 1100, 18) + valid = compile_graph( + graph, + source_image_ids={"original-front"}, + dimension_ledger={ + "x": {"value_mm": 800, "source_image_ids": ["original-front"]}, + "y": {"value_mm": 400, "source_image_ids": ["original-front"]}, + }, + ) + self.assertTrue(valid["ok"], valid["issues"]) + graph["envelope_mm"] = ["d", "w", "h"] + swapped = compile_graph( + graph, + source_image_ids={"original-front"}, + dimension_ledger={ + "x": {"value_mm": 800, "source_image_ids": ["original-front"]}, + "y": {"value_mm": 400, "source_image_ids": ["original-front"]}, + }, + ) + conflicts = [row for row in swapped["issues"] if row["code"] == "overall_dimension_conflict"] + self.assertEqual({"x", "y"}, {row["axis"] for row in conflicts}) + + def test_dimension_ledger_rejects_local_low_confidence_and_conflicting_arrows(self): + ledger = build_dimension_ledger([ + {"axis": "x", "value": 80, "unit": "cm", "scope": "overall", "confidence": 0.95, + "text": "80", "location": "外轮廓左右"}, + {"axis": "y", "value": 40, "unit": "cm", "scope": "part", "confidence": 0.99}, + {"axis": "z", "value": 1100, "unit": "mm", "scope": "overall", "confidence": 0.72}, + ], source_image_id="dimension") + self.assertEqual(800, ledger["x"]["value_mm"]) + self.assertNotIn("y", ledger) + self.assertNotIn("z", ledger) + conflicted = build_dimension_ledger([ + {"axis": "x", "value": 800, "unit": "mm", "scope": "overall", "confidence": 0.95}, + {"axis": "x", "value": 900, "unit": "mm", "scope": "overall", "confidence": 0.95}, + ], source_image_id="dimension") + self.assertNotIn("x", conflicted) + self.assertEqual("x", conflicted["conflicts"][0]["axis"]) + + def test_contacts_are_derived_from_geometry_within_declared_assembly(self): + graph = frame(800, 400, 1100, 18) + graph["connections"] = [] + graph["contact_derivation"] = "aabb_within_assembly_v1" + result = compile_case(graph) + self.assertTrue(result["ok"], result["issues"]) + self.assertEqual(2, len(result["derived_contacts"])) + self.assertEqual({"edge_to_face"}, {row["interface_type"] for row in result["derived_contacts"]}) + self.assertEqual({"i3"}, {row["edge_instance_id"] for row in result["derived_contacts"]}) + graph["instances"][2]["position_mm"][0] = "t+5" + broken = compile_case(graph) + self.assertFalse(broken["ok"]) + self.assertIn("disconnected_instance", {row["code"] for row in broken["issues"]}) + + def test_contacts_do_not_fabricate_cross_assembly_drawer_joint(self): + graph = frame(800, 400, 1100, 18) + graph["connections"] = [] + graph["contact_derivation"] = "aabb_within_assembly_v1" + graph["instances"][2]["assembly_id"] = "drawer-1" + result = compile_case(graph) + self.assertFalse(result["ok"]) + self.assertFalse(result["derived_contacts"]) + graph["connections"] = [ + {"id": "slide-left", "kind": "sliding", "a": "i1", "b": "i3"}, + {"id": "slide-right", "kind": "sliding", "a": "i2", "b": "i3"}, + ] + self.assertTrue(compile_case(graph)["ok"]) + + def test_anchor_constraints_compute_coordinates_instead_of_trusting_ai_xyz(self): + graph = anchored_frame() + result = compile_case(graph) + self.assertTrue(result["ok"], result["issues"]) + positions = {row["id"]: row["position_mm"] for row in result["instances"]} + self.assertEqual([0, 0, 0], positions["i1"]) + self.assertEqual([782, 0, 0], positions["i2"]) + self.assertEqual([18, 0, 1082], positions["i3"]) + self.assertEqual(9, len(result["placement_audit"])) + self.assertEqual(2, len(result["derived_contacts"])) + + def test_own_assembly_stable_id_is_a_safe_anchor_alias(self): + graph = anchored_frame() + for instance in graph["instances"]: + for axis in "xyz": + rule = instance["placement"][axis] + if rule.get("target") == "assembly": + rule["target"] = instance["assembly_id"] + aliased = compile_case(graph) + canonical = compile_case(anchored_frame()) + self.assertEqual( + [row["position_mm"] for row in canonical["instances"]], + [row["position_mm"] for row in aliased["instances"]], + ) + self.assertTrue(all( + row["target_kind"] == "assembly" + for row in aliased["placement_audit"] if row["target"] == "root" + )) + + invalid = anchored_frame() + invalid["instances"][0]["placement"]["x"]["target"] = "other-assembly" + self.assertIn("placement_constraint", {row["code"] for row in compile_case(invalid)["issues"]}) + + def test_anchor_constraints_follow_parameter_changes(self): + graph = anchored_frame() + first = compile_case(graph) + graph["parameters"]["w"]["value"] = 920 + graph["parameters"]["h"]["value"] = 1250 + second = compile_case(graph) + self.assertTrue(second["ok"], second["issues"]) + positions = {row["id"]: row["position_mm"] for row in second["instances"]} + self.assertEqual([902, 0, 0], positions["i2"]) + self.assertEqual([18, 0, 1232], positions["i3"]) + self.assertNotEqual(first["graph_sha256"], second["graph_sha256"]) + + def test_anchor_constraints_reject_missing_axis_and_dependency_cycle(self): + missing = anchored_frame() + del missing["instances"][0]["placement"]["z"] + self.assertIn("placement_constraint", {row["code"] for row in compile_case(missing)["issues"]}) + cyclic = anchored_frame() + cyclic["instances"][0]["placement"]["x"] = { + "mode": "align", "self_anchor": "min", "target": "i2", "target_anchor": "min" + } + cyclic["instances"][1]["placement"]["x"] = { + "mode": "align", "self_anchor": "min", "target": "i1", "target_anchor": "min" + } + self.assertIn("placement_constraint", {row["code"] for row in compile_case(cyclic)["issues"]}) + + def test_assembly_motion_connects_whole_subassembly_not_arbitrary_board(self): + graph = anchored_frame_with_drawer_assembly() + result = compile_case(graph) + self.assertTrue(result["ok"], result["issues"]) + self.assertEqual("sliding", result["assembly_connection_audit"][0]["kind"]) + self.assertEqual("y", result["assembly_connection_audit"][0]["axis"]) + graph["assembly_connections"] = [] + broken = compile_case(graph) + self.assertIn("disconnected_assembly", {row["code"] for row in broken["issues"]}) + + def test_assembly_motion_rejects_instance_endpoints_with_actionable_mapping(self): + graph = anchored_frame_with_drawer_assembly() + graph["assembly_connections"][0]["a"] = "drawer-solid" + graph["assembly_connections"][0]["b"] = "i1" + result = compile_case(graph) + problem = next( + row for row in result["issues"] + if row["code"] == "assembly_connection_uses_instance" + ) + self.assertEqual( + {"drawer-solid": "drawer-1", "i1": "root"}, + problem["instance_endpoint_to_current_assembly"], + ) + self.assertEqual( + "drawer-solid", problem["moving_endpoint_repair"]["instance_id"] + ) + self.assertIn("root", problem["stationary_endpoint_repair"]) + + def test_fixed_assembly_relation_requires_real_geometric_contact(self): + graph = anchored_frame_with_drawer_assembly() + graph["assembly_connections"][0] = { + "id": "not-really-fixed", "kind": "fixed", "a": "drawer-1", "b": "root", + "evidence_ids": ["view"], + } + result = compile_case(graph) + self.assertIn("fixed_assemblies_not_contacting", {row["code"] for row in result["issues"]}) + + def test_assembly_joint_cannot_hide_loose_member(self): + graph = anchored_frame_with_drawer_assembly() + graph["parts"][-1]["observed_quantity"] = 2 + loose = copy.deepcopy(graph["instances"][-1]) + loose["id"] = "drawer-loose" + loose["placement"]["z"] = {"mode": "absolute", "value_mm": 800} + graph["instances"].append(loose) + result = compile_case(graph) + self.assertIn("assembly_internal_disconnected", {row["code"] for row in result["issues"]}) + + def test_module_local_bounds_prevent_upper_module_from_falling_to_product_floor(self): + graph = anchored_frame() + graph["parameters"]["lower_h"] = param(420) + graph["parts"][0]["size_mm"][2] = "h-lower_h" + graph["assemblies"] = [{ + "id": "root", "role": "carcass", "evidence_ids": ["view"], + "bounds_mm": {"x": [0, "w"], "y": [0, "d"], "z": ["lower_h", "h"]}, + }] + for instance in graph["instances"][:2]: + instance["placement"]["x"]["target"] = "assembly" + instance["placement"]["y"]["target"] = "assembly" + instance["placement"]["z"]["target"] = "assembly" + graph["instances"][2]["placement"]["y"]["target"] = "assembly" + graph["instances"][2]["placement"]["z"]["target"] = "assembly" + result = compile_case(graph) + self.assertTrue(result["ok"], result["issues"]) + positions = {row["id"]: row["position_mm"] for row in result["instances"]} + self.assertEqual(420, positions["i1"][2]) + self.assertEqual(420, positions["i2"][2]) + graph["parameters"]["lower_h"]["value"] = 510 + moved = compile_case(graph) + self.assertTrue(moved["ok"], moved["issues"]) + self.assertEqual(510, {row["id"]: row["position_mm"] for row in moved["instances"]}["i1"][2]) + + def test_part_outside_assembly_and_solid_interference_are_blocking(self): + outside = anchored_frame_with_drawer_assembly() + outside["instances"][-1]["placement"]["z"] = {"mode": "absolute", "value_mm": 650} + self.assertIn("outside_assembly_bounds", {row["code"] for row in compile_case(outside)["issues"]}) + + collision = anchored_frame() + collision["instances"][2]["placement"]["x"] = {"mode": "absolute", "value_mm": 10} + result = compile_case(collision) + self.assertIn("solid_interference", {row["code"] for row in result["issues"]}) + + def test_anchored_duplicate_instance_id_and_loose_assembly_bounds_are_blocking(self): + duplicate = anchored_frame() + duplicate["instances"].append(copy.deepcopy(duplicate["instances"][0])) + result = compile_case(duplicate) + self.assertIn("instance_geometry", {row["code"] for row in result["issues"]}) + self.assertEqual(3, len(result["instances"])) + + loose_bounds = anchored_frame_with_drawer_assembly() + loose_bounds["assemblies"][1]["bounds_mm"]["z"][1] = 650 + result = compile_case(loose_bounds) + self.assertIn("assembly_bounds_not_tight", {row["code"] for row in result["issues"]}) + + def test_panel_role_requires_a_valid_local_thickness_axis(self): + missing = frame(800, 400, 1100, 18) + missing["parts"][0].pop("thickness_axis") + self.assertIn("part_geometry", {row["code"] for row in compile_case(missing)["issues"]}) + wrong = frame(800, 400, 1100, 18) + wrong["parts"][0]["thickness_axis"] = "z" + self.assertIn("part_geometry", {row["code"] for row in compile_case(wrong)["issues"]}) + + +if __name__ == "__main__": + unittest.main() diff --git a/server_patch/ai_3d_model/ai_3d_model_engine.py b/server_patch/ai_3d_model/ai_3d_model_engine.py new file mode 100644 index 0000000..828a095 --- /dev/null +++ b/server_patch/ai_3d_model/ai_3d_model_engine.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +import ast +import hashlib +import json +import os +import pathlib +import re +import resource +import sqlite3 +import subprocess +import time +import urllib.error +import urllib.request +import uuid +from datetime import datetime, timezone +from typing import Callable + + +ProgressCallback = Callable[[str, str, int, int], None] + + +class AI3DModelError(RuntimeError): + pass + + +class AI3DModelEngine: + """STEP-first text/image-to-CAD runner with an untrusted-code sandbox.""" + + TERMINAL_STAGES = {"completed", "failed"} + ALLOWED_IMPORT_ROOTS = {"build123d", "math"} + BANNED_CALLS = { + "compile", "eval", "exec", "getattr", "globals", "input", "locals", + "open", "setattr", "vars", "__import__", + } + + def __init__(self) -> None: + self.state_dir = pathlib.Path( + os.environ.get("DALONGXIA_AI_3D_STATE_DIR", "/var/lib/dalongxia-chat/ai-3d-model") + ) + self.runtime_dir = pathlib.Path( + os.environ.get("DALONGXIA_AI_3D_RUNTIME_DIR", "/opt/dalongxia-cad-runtime") + ) + self.skill_dir = pathlib.Path( + os.environ.get("DALONGXIA_AI_3D_SKILL_DIR", "/opt/dalongxia-chat-api/ai-3d-model/cad") + ) + self.model = os.environ.get("DALONGXIA_AI_3D_MODEL", "qwen3-coder-plus") + self.vision_model = os.environ.get("DALONGXIA_AI_3D_VISION_MODEL", "qwen-vl-plus") + self.api_base = os.environ.get( + "DALONGXIA_AI_3D_API_BASE", + "https://dashscope.aliyuncs.com/compatible-mode/v1", + ).rstrip("/") + self.timeout = max(60, int(os.environ.get("DALONGXIA_AI_3D_TIMEOUT", "300"))) + self.state_dir.mkdir(parents=True, exist_ok=True) + (self.state_dir / "jobs").mkdir(parents=True, exist_ok=True) + self.db_path = self.state_dir / "jobs.sqlite3" + self._init_db() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, timeout=20) + conn.row_factory = sqlite3.Row + return conn + + def _init_db(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + context_key TEXT NOT NULL, + prompt TEXT NOT NULL, + stage TEXT NOT NULL, + display_text TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + result_json TEXT NOT NULL DEFAULT '{}', + error TEXT NOT NULL DEFAULT '' + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_ai3d_context_updated ON jobs(context_key, updated_at DESC)" + ) + + @staticmethod + def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + @staticmethod + def context_key(conversation_id: str, user_id: str, session_key: str) -> str: + raw = "|".join([str(user_id or ""), str(conversation_id or ""), str(session_key or "")]) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + def _insert_job(self, context_key: str, prompt: str) -> dict: + job_id = f"cad_{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}" + now = self._now() + with self._connect() as conn: + conn.execute( + "INSERT INTO jobs(job_id,context_key,prompt,stage,display_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?)", + (job_id, context_key, prompt, "queued", "任务已进入CAD生成队列", now, now), + ) + return self.get_job(job_id) or {} + + def _update_job(self, job_id: str, stage: str, display_text: str, *, result=None, error="") -> dict: + now = self._now() + with self._connect() as conn: + conn.execute( + "UPDATE jobs SET stage=?,display_text=?,updated_at=?,result_json=?,error=? WHERE job_id=?", + (stage, display_text, now, json.dumps(result or {}, ensure_ascii=False), str(error or "")[:4000], job_id), + ) + return self.get_job(job_id) or {} + + def get_job(self, job_id: str) -> dict | None: + with self._connect() as conn: + row = conn.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone() + if not row: + return None + value = dict(row) + try: + value["result"] = json.loads(value.pop("result_json") or "{}") + except json.JSONDecodeError: + value["result"] = {} + return value + + def latest_job(self, context_key: str) -> dict | None: + with self._connect() as conn: + row = conn.execute( + "SELECT job_id FROM jobs WHERE context_key=? ORDER BY updated_at DESC LIMIT 1", + (context_key,), + ).fetchone() + return self.get_job(str(row["job_id"])) if row else None + + @staticmethod + def _is_entry_only(message: str) -> bool: + compact = re.sub(r"[\s,。!?!?、::]+", "", str(message or "").lower()) + compact = re.sub(r"【技能路由[::]ai-3d-model】", "", compact) + return compact in {"", "ai画3d模型", "ai画3d", "生成3d模型", "文字生成cad"} + + @staticmethod + def _is_progress_request(message: str) -> bool: + text = str(message or "").strip() + return bool(re.fullmatch(r"(?:继续|进度|查看进度|现在怎么样|完成了吗|结果呢)[??。\s]*", text)) + + def _api_key(self) -> str: + direct = os.environ.get("DASHSCOPE_API_KEY", "").strip() + if direct: + return direct + key_path = os.environ.get("DASHSCOPE_API_KEY_FILE", "").strip() + if key_path: + try: + return pathlib.Path(key_path).read_text(encoding="utf-8").strip() + except OSError as exc: + raise AI3DModelError("CAD模型服务密钥文件不可读") from exc + raise AI3DModelError("CAD模型服务尚未配置密钥") + + @staticmethod + def _has_image_intent(prompt: str) -> bool: + text = re.sub(r"\s+", "", str(prompt or "")).lower() + return any( + marker in text + for marker in ( + "根据图片", "根据上传", "按图", "参考图", "示意图", "工程图", + "图中标注", "照着图", "图片创建", "图片生成", "image-to-cad", + ) + ) + + @staticmethod + def _vision_content(image_inputs: list[dict], prompt: str) -> list[dict]: + content: list[dict] = [] + for item in image_inputs[:4]: + if not isinstance(item, dict): + continue + kind = str(item.get("kind") or "") + value = str(item.get("value") or "").strip() + if kind == "image_url" and re.match(r"^https?://", value, re.I): + content.append({"type": "image_url", "image_url": {"url": value}}) + elif kind == "image_base64" and value.startswith("data:image/"): + content.append({"type": "image_url", "image_url": {"url": value}}) + content.append( + { + "type": "text", + "text": ( + "请作为机械CAD读图工程师,只提取图像中有证据的几何与尺寸。" + "识别外形、孔/槽/凸台、对称关系、截面、标注数值与单位。" + "不要凭空添加图中没有的结构;看不清的内容必须放入uncertainties。" + "返回一个JSON对象,字段为summary、geometry、dimensions、uncertainties、modeling_instruction。\n" + f"用户要求:{prompt.strip()}" + ), + } + ) + return content + + def _visual_spec(self, prompt: str, image_inputs: list[dict]) -> dict: + content = self._vision_content(image_inputs, prompt) + if not any(item.get("type") == "image_url" for item in content): + raise AI3DModelError("图片内容不完整,请上传可读取的PNG/JPG/WebP图片") + body = { + "model": self.vision_model, + "messages": [{"role": "user", "content": content}], + "temperature": 0.0, + } + request = urllib.request.Request( + f"{self.api_base}/chat/completions", + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Authorization": f"Bearer {self._api_key()}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + content_value = payload["choices"][0]["message"]["content"] + if isinstance(content_value, list): + content_value = "".join( + str(item.get("text") or "") for item in content_value if isinstance(item, dict) + ) + raw = str(content_value or "").strip() + raw = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.S) + match = re.search(r"\{.*\}", raw, flags=re.S) + spec = json.loads(match.group(0) if match else raw) + except (urllib.error.URLError, TimeoutError, KeyError, IndexError, TypeError, json.JSONDecodeError) as exc: + raise AI3DModelError(f"CAD图片理解失败:{exc}") from exc + if not isinstance(spec, dict) or not str(spec.get("modeling_instruction") or "").strip(): + raise AI3DModelError("CAD图片理解未返回可建模的结构和尺寸") + return spec + + def _model_source( + self, + prompt: str, + repair_error: str = "", + previous_source: str = "", + visual_spec: dict | None = None, + ) -> tuple[str, dict]: + system = """你是参数化机械CAD工程师。把中文需求转换成安全的 build123d Python。 +只返回一个JSON对象:{"title":"简短中文名","assumptions":["..."],"source":"..."}。 +source必须:1) 只允许 import math 或 from build123d import ...;2) 定义无参数函数 gen_step(); +3) 返回一个闭合的正体积 Part、Solid、Compound 或带标签的装配;4) 单位毫米;5) 使用明确命名参数; +6) 不读写文件、不访问网络、环境变量或系统,不使用动态执行;7) 不调用 show_object;8) 简单需求也要完整建模。 +优先使用 Box、Cylinder、Sphere、Cone、BuildPart、BuildSketch、Locations、extrude、fillet、chamfer、Hole。 +不要返回Markdown代码围栏,不要臆造工程认证或承载能力。""" + user = f"用户CAD需求:\n{prompt.strip()}" + if visual_spec: + user += ( + "\n\n以下是视觉模型从用户图片提取的读图结果。" + "建模必须以其中可见标注为准,不得用常见尺寸替换:\n" + + json.dumps(visual_spec, ensure_ascii=False) + ) + if repair_error: + user += f"\n\n上一次源码构建失败,请仅修复导致失败的部分。错误:\n{repair_error[-3000:]}" + user += f"\n\n上一次源码:\n{previous_source[-10000:]}" + body = { + "model": self.model, + "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], + "temperature": 0.1, + "response_format": {"type": "json_object"}, + } + request = urllib.request.Request( + f"{self.api_base}/chat/completions", + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Authorization": f"Bearer {self._api_key()}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + raise AI3DModelError(f"CAD模型服务调用失败:{exc}") from exc + try: + content = payload["choices"][0]["message"]["content"] + if isinstance(content, list): + content = "".join(str(x.get("text") or "") for x in content if isinstance(x, dict)) + text = str(content).strip() + if text.startswith("```"): + text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text, flags=re.S) + result = json.loads(text) + source = str(result.get("source") or "").strip() + except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc: + raise AI3DModelError("CAD模型服务未返回有效源码") from exc + self._validate_source(source) + return source, result + + def _validate_source(self, source: str) -> None: + if not source or len(source) > 50_000: + raise AI3DModelError("生成的CAD源码为空或过长") + try: + tree = ast.parse(source) + except SyntaxError as exc: + raise AI3DModelError(f"生成源码语法错误:{exc.msg}") from exc + has_gen_step = False + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + names = [alias.name.split(".")[0] for alias in node.names] if isinstance(node, ast.Import) else [(node.module or "").split(".")[0]] + if any(name not in self.ALLOWED_IMPORT_ROOTS for name in names): + raise AI3DModelError("生成源码包含不允许的模块") + elif isinstance(node, ast.FunctionDef) and node.name == "gen_step" and not node.args.args: + has_gen_step = True + elif isinstance(node, ast.Call): + name = node.func.id if isinstance(node.func, ast.Name) else "" + if name in self.BANNED_CALLS: + raise AI3DModelError("生成源码包含不安全调用") + elif isinstance(node, ast.Attribute) and str(node.attr).startswith("__"): + raise AI3DModelError("生成源码包含不安全属性访问") + if not has_gen_step: + raise AI3DModelError("生成源码缺少无参数 gen_step()") + + @staticmethod + def _limits() -> None: + resource.setrlimit(resource.RLIMIT_CPU, (180, 180)) + resource.setrlimit(resource.RLIMIT_AS, (5 * 1024**3, 5 * 1024**3)) + resource.setrlimit(resource.RLIMIT_FSIZE, (512 * 1024**2, 512 * 1024**2)) + resource.setrlimit(resource.RLIMIT_NOFILE, (256, 256)) + # RLIMIT_NPROC is per Linux uid, and the shared Chat API uid already owns + # browser workers. Keep a finite ceiling without blocking namespace setup. + resource.setrlimit(resource.RLIMIT_NPROC, (2048, 2048)) + + def _sandbox_command(self, job_dir: pathlib.Path, argv: list[str]) -> list[str]: + command = [ + "bwrap", "--unshare-net", "--unshare-pid", "--unshare-ipc", "--unshare-uts", + "--die-with-parent", "--new-session", + "--tmpfs", "/", "--ro-bind", "/proc", "/proc", "--dev", "/dev", "--tmpfs", "/dev/shm", + "--dir", "/opt", "--dir", "/opt/dalongxia-cad-runtime", + "--dir", "/opt/dalongxia-chat-api", "--dir", "/opt/dalongxia-chat-api/ai-3d-model", + "--dir", "/work", "--tmpfs", "/tmp", + "--ro-bind", "/usr", "/usr", "--ro-bind", "/lib", "/lib", + "--ro-bind", "/etc", "/etc", "--ro-bind", "/sys", "/sys", + ] + if pathlib.Path("/lib64").exists(): + command += ["--ro-bind", "/lib64", "/lib64"] + command += [ + "--ro-bind", str(self.runtime_dir), str(self.runtime_dir), + "--ro-bind", str(self.skill_dir), str(self.skill_dir), + "--bind", str(job_dir), "/work", "--chdir", "/work", + "--setenv", "HOME", "/tmp", "--setenv", "TMPDIR", "/tmp", + "--setenv", "PATH", f"{self.runtime_dir}/venv/bin:/usr/bin:/bin", + "--clearenv", + ] + # Environment is rebuilt after clearenv by bwrap in command order. + command += [ + "--setenv", "HOME", "/tmp", "--setenv", "TMPDIR", "/tmp", + "--setenv", "PATH", f"{self.runtime_dir}/venv/bin:/usr/bin:/bin", + "--setenv", "PLAYWRIGHT_BROWSERS_PATH", f"{self.runtime_dir}/ms-playwright", + ] + return command + argv + + def _run(self, job_dir: pathlib.Path, argv: list[str], *, stdin: str = "", timeout: int = 240) -> subprocess.CompletedProcess: + proc = subprocess.run( + self._sandbox_command(job_dir, argv), input=stdin, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, + preexec_fn=self._limits, env={"PATH": "/usr/bin:/bin"}, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "CAD工具执行失败")[-5000:] + raise AI3DModelError(detail) + return proc + + def _run_trusted_snapshot( + self, + job_dir: pathlib.Path, + argv: list[str], + *, + stdin: str, + timeout: int, + ) -> subprocess.CompletedProcess: + """Render an already-validated STEP outside bwrap. + + Playwright cannot spawn Chromium from this host's unprivileged user + namespace. The renderer receives only the inert STEP artifact, never the + generated Python source, and gets a minimal environment. + """ + env = { + "HOME": "/tmp", + "TMPDIR": "/tmp", + "PATH": f"{self.runtime_dir}/venv/bin:/usr/bin:/bin", + "PLAYWRIGHT_BROWSERS_PATH": str(self.runtime_dir / "ms-playwright"), + } + proc = subprocess.run( + argv, input=stdin, text=True, cwd=job_dir, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, + env=env, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "CAD预览执行失败")[-5000:] + raise AI3DModelError(detail) + return proc + + @staticmethod + def _requested_exports(prompt: str) -> list[str]: + upper = str(prompt or "").upper() + return [fmt for fmt in ("STL", "3MF", "GLB") if fmt in upper] + + def _build_and_validate( + self, + job_dir: pathlib.Path, + source_name: str, + requested_exports: list[str] | None = None, + progress: ProgressCallback | None = None, + ) -> dict: + python = str(self.runtime_dir / "venv/bin/python") + gen = str(self.skill_dir / "scripts/gen") + inspect_tool = str(self.skill_dir / "scripts/inspect") + snapshot = str(self.skill_dir / "scripts/snapshot") + self._run(job_dir, [python, gen, source_name, "--write", "--json"], timeout=self.timeout) + step_name = source_name[:-3] + step_path = job_dir / step_name + if not step_path.is_file() or step_path.stat().st_size < 100: + raise AI3DModelError("STEP文件没有生成") + if progress: + progress("validating", "STEP几何已生成,正在复核闭合性、体积和尺寸边界", 4, 5) + validation = self._run(job_dir, [python, inspect_tool, "validate", step_name], timeout=self.timeout) + # Per-solid validity does not detect two individually valid parts + # occupying the same volume. Run the geometry kernel's pairwise + # boolean-intersection audit before producing a preview or claiming a + # usable assembly. The inspect command exits non-zero on a clash, so + # _run turns it into a repairable CAD failure instead of a false pass. + interference = self._run( + job_dir, + # The bundled inspect CLI exposes structured output through + # ``--format json`` (and already defaults to JSON on newer + # versions). ``--json`` is not accepted by the runtime currently + # installed on the server. + [python, inspect_tool, "interfere", step_name, "--format", "json"], + timeout=self.timeout, + ) + refs = self._run( + job_dir, + [python, inspect_tool, "refs", step_name, "--facts", "--planes", "--positioning"], + timeout=self.timeout, + ) + preview_base = str(job_dir / "preview.png") + snapshot_job = { + "input": str(step_path), "mode": "view", + "outputs": [{"path": preview_base, "camera": "iso"}], + "render": {"viewLabels": True, "padding": 0.12, "sizeProfile": "diagnostic"}, + } + if progress: + progress("rendering", "几何校验通过,正在生成等轴预览图", 4, 5) + self._run_trusted_snapshot( + job_dir, + [python, snapshot, "--job", "-", "--json"], + stdin=json.dumps(snapshot_job), + timeout=self.timeout, + ) + previews = sorted(job_dir.glob("preview*.png"), key=lambda p: p.stat().st_mtime, reverse=True) + if not previews: + raise AI3DModelError("模型预览图没有生成") + result = { + "step_path": str(step_path), "source_path": str(job_dir / source_name), + "preview_path": str(previews[0]), "validation": validation.stdout.strip()[-5000:], + "interference": interference.stdout.strip()[-12000:], + "facts": refs.stdout.strip()[-12000:], + } + formats = [str(item).upper() for item in (requested_exports or [])] + if formats: + export_tool = str(self.skill_dir / "scripts/export") + flags = [f"--{fmt.lower()}" for fmt in formats] + self._run(job_dir, [python, export_tool, step_name, *flags, "--json"], timeout=self.timeout) + for fmt in formats: + export_path = step_path.with_suffix(f".{fmt.lower()}") + if not export_path.is_file() or export_path.stat().st_size < 100: + raise AI3DModelError(f"{fmt}文件没有生成") + result[f"{fmt.lower()}_path"] = str(export_path) + return result + + def run( + self, + message: str, + *, + conversation_id: str, + user_id: str, + session_key: str, + progress: ProgressCallback | None = None, + image_inputs: list[dict] | None = None, + ) -> dict: + image_inputs = [item for item in (image_inputs or []) if isinstance(item, dict)][:4] + context_key = self.context_key(conversation_id, user_id, session_key) + if self._is_entry_only(message): + return { + "status": "waiting", + "answer": "### ai画3d模型\n\n请直接在对话框描述要画的模型和关键尺寸。默认单位为毫米,主文件为 STEP。\n\n例如:`画一个80×50×4毫米的L形安装支架,两边各有2个直径5毫米安装孔,圆角2毫米,同时导出STL。`", + "files": [], + } + + if self._is_progress_request(message): + latest = self.latest_job(context_key) + if not latest: + return {"status": "waiting", "answer": "### ai画3d模型\n\n当前会话还没有CAD任务,请直接描述模型和尺寸。", "files": []} + result = latest.get("result") or {} + if latest["stage"] == "completed": + return {"status": "completed", "answer": self._completed_answer(latest), "files": result.get("files") or []} + if latest["stage"] == "failed": + return {"status": "failed", "answer": f"### ai画3d模型未完成\n\n{latest['display_text']}\n\n失败原因:{latest['error']}", "files": []} + return {"status": "waiting", "answer": f"### ai画3d模型进度\n\n{latest['display_text']}\n\n任务号:`{latest['job_id']}`", "files": []} + + if self._has_image_intent(message) and not image_inputs: + return { + "status": "waiting", + "answer": ( + "### ai画3d模型\n\n我收到了按图建模的要求,但当前请求没有可读取的图片内容。" + "请直接上传PNG、JPG或WebP图片,不会在没有读图证据时自行猜测尺寸。" + ), + "files": [], + } + + job = self._insert_job(context_key, message) + job_id = str(job["job_id"]) + job_dir = self.state_dir / "jobs" / job_id + job_dir.mkdir(parents=True, exist_ok=False) + source_name = "model.step.py" + assumptions: list[str] = [] + + def emit(stage: str, text: str, current: int) -> None: + self._update_job(job_id, stage, text) + if progress: + progress(stage, text, current, 5) + + def build_progress(stage: str, text: str, current: int, total: int) -> None: + self._update_job(job_id, stage, text) + if progress: + progress(stage, text, current, total) + + try: + visual_spec: dict | None = None + if image_inputs: + emit("vision", "正在识别图片中的轮廓、孔位、尺寸标注和建模约束", 1) + visual_spec = self._visual_spec(message, image_inputs) + emit("specifying", "图片读图完成,正在把可见标注整理为参数化建模要求", 2) + else: + emit("specifying", "正在把文字需求整理为尺寸、特征和建模约束", 1) + source = "" + last_error = "" + meta: dict = {} + for attempt in range(1, 3): + emit("coding", "正在生成参数化CAD源码" if attempt == 1 else "首次构建未通过,正在自动修复源码", 2) + source, meta = self._model_source( + message, + last_error, + source, + visual_spec=visual_spec, + ) + (job_dir / source_name).write_text(source + "\n", encoding="utf-8") + assumptions = [str(x) for x in (meta.get("assumptions") or []) if str(x).strip()][:10] + try: + emit("building", "正在无网络隔离沙箱中构建STEP几何", 3) + built = self._build_and_validate( + job_dir, + source_name, + requested_exports=self._requested_exports(message), + progress=build_progress, + ) + break + except Exception as exc: + last_error = str(exc) + if attempt >= 2: + raise + result = { + **built, "assumptions": assumptions, "title": str(meta.get("title") or "3D模型"), + "files": [], + } + emit("delivering", "模型、源码和预览已生成,正在上传交付文件", 4) + self._update_job(job_id, "delivering", "模型已完成并通过几何校验,正在上传交付文件", result=result) + completed = self.get_job(job_id) or {} + return { + "status": "completed", "answer": self._completed_answer(completed), + "files": [], "local_files": built, "job_id": job_id, + } + except Exception as exc: + friendly = re.sub(r"\s+", " ", str(exc)).strip()[-1000:] or "CAD生成失败" + self._update_job(job_id, "failed", "模型生成未完成", error=friendly) + return { + "status": "failed", + "answer": f"### ai画3d模型未完成\n\n任务没有被错误标记为成功。系统已自动生成并修复两轮,但几何校验仍未通过。\n\n失败原因:{friendly}", + "files": [], "job_id": job_id, + } + + def attach_files(self, job_id: str, files: list[dict]) -> dict | None: + job = self.get_job(job_id) + if not job: + return None + result = dict(job.get("result") or {}) + result["files"] = [dict(item) for item in files if isinstance(item, dict)] + return self._update_job( + job_id, + "completed", + "模型已完成并通过几何校验,交付文件已上传", + result=result, + error=str(job.get("error") or ""), + ) + + def mark_delivery_failed(self, job_id: str, error: str) -> dict | None: + job = self.get_job(job_id) + if not job: + return None + return self._update_job( + job_id, + "failed", + "模型已生成,但交付文件上传失败", + result=dict(job.get("result") or {}), + error=str(error or "文件上传失败")[:1000], + ) + + @staticmethod + def _completed_answer(job: dict) -> str: + result = job.get("result") or {} + assumptions = result.get("assumptions") or [] + assumptions_text = "\n".join(f"- {x}" for x in assumptions) or "- 未使用额外假设" + return ( + f"### ai画3d模型完成\n\n模型已生成,并完成 STEP 几何校验与预览复核。\n\n" + f"- 任务号:`{job.get('job_id')}`\n- 模型:{result.get('title') or '3D模型'}\n" + f"- 主交付:STEP\n- 可编辑源码:build123d Python\n\n使用的建模假设:\n{assumptions_text}" + ) diff --git a/server_patch/ai_3d_model/test_ai_3d_model_engine.py b/server_patch/ai_3d_model/test_ai_3d_model_engine.py new file mode 100644 index 0000000..2f069b8 --- /dev/null +++ b/server_patch/ai_3d_model/test_ai_3d_model_engine.py @@ -0,0 +1,73 @@ +import json +import pathlib +import subprocess +import tempfile +import unittest + +from ai_3d_model_engine import AI3DModelEngine, AI3DModelError + + +class BuildValidationPipelineTest(unittest.TestCase): + def engine(self): + value = AI3DModelEngine.__new__(AI3DModelEngine) + value.runtime_dir = pathlib.Path("/runtime") + value.skill_dir = pathlib.Path("/skill") + value.timeout = 30 + return value + + def test_interference_runs_before_preview_and_is_returned(self): + engine = self.engine() + calls = [] + + def fake_run(job_dir, argv, *, stdin="", timeout=0): + calls.append(("run", list(argv))) + if "gen" in argv[1]: + (job_dir / "model.step").write_bytes(b"STEP" * 40) + command = argv[2] if len(argv) > 2 else "" + return subprocess.CompletedProcess(argv, 0, stdout=json.dumps({"ok": True, "command": command}), stderr="") + + def fake_snapshot(job_dir, argv, *, stdin, timeout): + calls.append(("snapshot", list(argv))) + payload = json.loads(stdin) + pathlib.Path(payload["outputs"][0]["path"]).write_bytes(b"PNG") + return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="") + + engine._run = fake_run + engine._run_trusted_snapshot = fake_snapshot + with tempfile.TemporaryDirectory() as folder: + result = engine._build_and_validate(pathlib.Path(folder), "model.step.py") + commands = [argv[2] for kind, argv in calls if kind == "run" and len(argv) > 2] + self.assertLess(commands.index("validate"), commands.index("interfere")) + self.assertLess(commands.index("interfere"), commands.index("refs")) + interfere_call = next( + argv for kind, argv in calls if kind == "run" and len(argv) > 2 and argv[2] == "interfere" + ) + self.assertEqual(["--format", "json"], interfere_call[-2:]) + self.assertEqual("interfere", json.loads(result["interference"])["command"]) + + def test_interference_failure_blocks_preview(self): + engine = self.engine() + snapshot_called = False + + def fake_run(job_dir, argv, *, stdin="", timeout=0): + if "gen" in argv[1]: + (job_dir / "model.step").write_bytes(b"STEP" * 40) + if len(argv) > 2 and argv[2] == "interfere": + raise AI3DModelError("two parts overlap") + return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="") + + def fake_snapshot(job_dir, argv, *, stdin, timeout): + nonlocal snapshot_called + snapshot_called = True + return subprocess.CompletedProcess(argv, 0, stdout="{}", stderr="") + + engine._run = fake_run + engine._run_trusted_snapshot = fake_snapshot + with tempfile.TemporaryDirectory() as folder: + with self.assertRaisesRegex(AI3DModelError, "overlap"): + engine._build_and_validate(pathlib.Path(folder), "model.step.py") + self.assertFalse(snapshot_called) + + +if __name__ == "__main__": + unittest.main()