报价确认、拒绝或过期只是报价到了终态,不等于这条数据已经能拿来校准。工作台新增 “待回填结果”队列与实绩覆盖率:终态报价没有任何实际结果时重新成为明确待办, 填过以后自动离队。没有仍然不是 0。
详情页原先只能填实际成本与工时,但偏差报表还需要实际成交价、废品数和结果—— 这些字段只存在于 JSON API,日常浏览器流程喂不出完整样本。现在同一张追加表单可填 五项,仍走原来的旁流、会话身份和幂等号,报价快照一个字节不动。
真实性约束下沉到账本自身:负价格、负工时、非整数废品、NaN/Infinity 和错误类型 一律拒绝,网页、JSON API 与将来的导入器都绕不过去;真实的 0 保留并在报表上显示 为 0,不再被 or "—" 吞成缺失。新增 18 条判据,关键两刀已做变异验证。
报价主线随版本化部署默认开启:生产与测试使用同一份 restart.sh,启动时显式传入 MM_FLOW=1。若线上出现问题,可在数据目录的仓外 env 写 MM_FLOW=0 后由自动部署 拉起,立即退回旧文件目录入口;已落盘台账与历史快照不删除、不迁移。
M2 把历史报价定成不可变,而回填要往上面加"后来实际花了多少"——直接改记录就破了 不可变。道理上也该分开:报价快照说的是"当时报了多少、按的什么依据",实际 结果说的是"后来真花了多少"。
所以回填走一条追加式旁流(v<N>.actuals.jsonl,哈希链),报价快照一个字节 不动,判据逐字节核过。
填错了就追加一条指回旧的,旧的留着不删——历史账本上划掉的数也是证据。被顶掉的 是整条:第一条填了成交价与工时,第二条更正时只填成交价,那条工时也跟着作废。
一条实际结果都没有 → verified=false,平均偏差给 None。对空集合取平均是 0, 而 0% 偏差读起来像"报价很准"——那是这一层能给出的最坏的一个数。
覆盖率永远印在最上面(1 / 2):只报平均值不报覆盖率,等于把"三条里有一条" 说成结论。币种不符、报价当时就没算出总价 → 那条偏差不给;报价是 0 → 给差额 不给百分比(没有分母的百分比是编出来的)。
来源(erp/manual/shopfloor/import/unknown)与可回填字段各走白名单:塞一个没人 认识的键进去,报表上永远看不到它,而填的人以为自己填了。
门禁新增 45 条(回填 30、服务 +15),24 刀变异逐一转红。五刀第一版没红:被顶掉 那条只判了同名字段(补"整条作废")、序号检查被哈希检查顶掉(补重盖章的伪造序号)、 零分母那条走的是不可比分支(补 total=0 的用例)、缺参数的 400 被 404 顶掉、 横幅标题被同页说明文字顶掉(改判加粗那一句)。按指令不跑全量:受影响 344 全绿。
goal 写死了边界:只用可解释字段,显示"为何相似"与字段差异,不做黑箱向量 结论。
数值字段就是"相差多少个百分比",分类字段一样得 1 不一样得 0,总分是参与比较那 几项的加权平均。权重明摆在 WEIGHTS 里、也印在页面上——看不见权重的 "相似度"就是黑箱。
这几个数不四舍五入:判据要验 score == Σ(match×weight) ÷ Σweight 精确成立, 舍进去一位那个恒等式就只剩"约等于",而"约等于"里能藏下一个说不清的系数。
一边没有那个字段就记进 missing,不参与加权。把"不知道"当成"很像",排在第一 条的就可能是个八竿子打不着的件。
几何上像的两条,币种/费率指纹/税口径对不上时,归一化价格一律 None 并说明 ——把数算出来印在旁边,读的人不会去看那行小字,他会看那个数。
材料默认硬过滤:钢件的价拿铝件来参考,参考的是个寂寞。
结果里带 corpus,写明这些是系统报过的价、不是成交价也不是实际成本, 带实际回填的有 0 条;本仓生产上可导入的历史报价为 0 条(M1 审计)。
applies_to_current_quote 恒为 false。历史价格不覆盖当前的材料价、工时费率或 规则结果——当前报价永远由确定性规则现算。
门禁新增 29 条(相似 19、服务 +10),17 刀变异逐一转红。两刀第一版没红:页面 判据只查"权重"两个字(每条结果的表头里也有),以及一次变异打错了函数——同一段 文本在 history_detail 里也有,replace(...,1) 取了第一处。按指令不跑全量: 受影响 365 全绿。
M2 建了库,M3 把它接上只读入口。
DRAFT / ISSUED 还会变——记下来的话同一个 (quote_id, version) 下一秒就换了内容, 不可变那道闸会当场炸。ACCEPTED / REJECTED / EXPIRED / SUPERSEDED 在状态机上都是 终态,记下来才立得住。
报没报成都记:"这个价没谈下来"本身就是一条要留的证据;只留成交的,校准数据 就只剩一半。写不进去就抛,不咽下去——咽下去的话页面上那份报价看着好好地 ACCEPTED 了,而历史库里什么都没有。
列表(八种筛选,键走白名单)、详情(可空字段与来源、成本分项含逐工位、明细)、 对比(逐字段 + 逐工位)。列表页唯一的表单是筛选(GET),详情与对比页没有表单。
币种 / 费率指纹 / 税口径任一对不上 → comparable=false,逐字段与逐工位的差额 一律 None。照样把差额印出来的话,读的人不会去看那行小字——他会看那个数。
逐工位里"只在一边出现"的工位要标出来,不当成 0:"这一版没走这道工序"与 "这道工序不要钱"是两回事。
清了流程台账却留着历史库,下一个判据用同一个 Q-1 建出内容不同的报价 → 历史库当场拒绝。那不是毛病,正是它该有的样子:同一个报价号同一版,内容只能 有一份。判据的清理夹具因此要把两处一起清。
门禁新增 33 条(历史库 +19、服务 +14),13 刀变异逐一转红——三刀第一版没红: 摘要里 lines 与明细同名(改成 line_count)、"不可比时逐工位也不给差额"断言落 在空列表上(恒真)、两个 400 的守卫被兜底顶掉(改判消息而不是状态码)。 按指令不跑全量:受影响 382 全绿;证据重跑逐字节一致。
产品裁定转向"报价驱动的生产准备",要把过去报过的单沉淀成可检索、可对比、 可校准的历史库。第一步是只读审计(docs/goals/p10-m1-audit.md)。
三条独立证据互相印证:没有 .metalmaster/flow(MM_FLOW 从未开过,没有报价 台账)、serve.log 里 POST /nest 出现 0 次(排样报价一次都没跑过)、 1360 份离线任务页里 1261 份含"报价"二字但含 ¥ 的是 0 份(那是零件分析页的 "报价输入"一节,不含金额)。
所以 M2 不导入、不回填。从 HTML 里抠数字拼成"历史报价",只会抠出一堆 unknown 拼的假记录——那正是 goal 里写的"不伪造历史数据"。
GroupPlan.hours{工位} 早算好了,只是没进报价对象—— 搬运不重算,判据核"逐工位之和 == 人工费"。绑着 content_hash,读回来重算记录哈希对不上就抛:记录与那份报价讲的不是同一 件事时,给出一个"历史价"比不给危险得多——它会被拿去和当前报价比。
可空字段必须说来源。unknown 不是空串:空串会被读成"这里是空的",而真相是 "我们不知道"。时间与报价人从审计台账推(带事件 seq 可回查),没台账就是 unknown, 不拿文件 mtime 之类的凑数;而且"压根没给台账"与"台账里没有这份报价"要分得开 ——前者补上就有了,后者补了也没有。
币种 / 费率指纹 / 税口径只要一样对不上,就是"不可直接比较"并逐条说明。
记录进出不动 QuoteVersion、费率表或规则结果一个字节。actuals 空 dict = 还没回填,不是"零"。
门禁新增 34 条,17 刀变异逐一转红——两刀第一版没红:没给台账与台账里没有这份 报价的 note 分不开、费率指纹的判据手工塞 sources 绕过了 build 那一头, 各补判据后红。按指令不跑全量:受影响 331 全绿;证据重跑两次逐字节一致。
P7 的门禁只在读的那条路上生效,而且读不出来就抛:工程师想问"这份包现在还完好 吗"只能触发一次导出等它炸——炸了才知道;客户拿到交付包无从自证。
判定就是调 pkgstore.load() + check_cursor(),把抛出来的异常按代号归到 九项检查中的一项。为此给 PackageSnapshotError 加了 code——不按中文消息做 字符串匹配:消息是给人看的、随时会改,代号才是给机器看的。
重写一遍校验就是同一件事两套实现,两边都会"看起来对",直到某天只有一边跟上了 新规则。
pass / fail / not_reached。load() 一遇到问题就抛,后面那些检查根本 没跑——把没跑的记成"通过"是这份报告能犯的最严重的错:一份"九项全过"的报告, 其实只查了前三项。
verified 只在 PASS 时为真;BLOCKED 时给出的指纹是快照自称的,报告里逐字 写明"没验过,不要拿它们当证据"。
按报价汇总时只要有一份 BLOCKED 整份就是 BLOCKED——交付包是整体交出去的, "七份里六份好"不是一个能拿去开工的结论;一份包都没有也算 BLOCKED。
不修复、不回填、不重算、不 GC;结论也不落盘——落下来就成了一份可被伪造的 证书,要就现算一次。判据盯着"验证前后链头一致、盘上字节一字未变"。
路由 /flow/verify.json?job=、?quote=、/flow/verify?quote=,都在 MM_FLOW 后面、默认 404 且说明原因;页面无表单,BLOCKED 真的标红(判据判的是用上了哪个 class,不是"这两个词在不在"——两个类名在内联 CSS 里都必然出现)。
门禁新增 27 条(验证 20、服务 +7),11 刀变异逐一转红。判据全部复用 P7 已有的 夹具(损坏/越权/路径/内容/游标/并发后的结果),不另造一套。本阶段不跑全量: 最小红灯 20 → 受影响 246 全绿。
P6 之后照代码查出三个洞,逐条补上。
判据真起两个进程去撞:同进程的两个线程即便没有 flock,也可能因为 GIL 与 调度恰好不撞上;而快照是要给别的进程(重启后的服务、离线脚本)读写的,锁不跨 进程就等于没有。
save 全程持独占 flock,拿到锁之后再查一次存在与否——第一次查与真正写之间 那段是缝,两个进程同时进来会双双查到"不在",不可变那道检查就被绕过去了。
文件先写进 tmp 目录,最后 os.replace 换整个目录:目录改名是原子的,要么 整份在、要么整份不在,不会留下"文件写了一半、清单还没写"。写砸了只清掉这一次 自己造的那个 tmp 目录——那是本次调用的残骸,不是任何人的快照(与被禁止的 GC 不是一回事)。
NUL 字节、json.loads、CSV 至少一行、DXF 的 SECTION/ENDSEC/EOF、STEP 的 ISO-10303-21——存与读各查一次:存那次挡脏数据进门,读那次挡盘上被人换过。 一份改名成 .json 的二进制、一份截了一半的 DXF,现在都进不来也读不出去。
只做能一眼看出"这不是它自称的那种东西"的那几刀。做深了会开始拒绝合法但少见的 写法,而那种假红比不红更糟——它会让人把整条判据关掉。
Ledger.package 从快照恢复之后,拿快照里那个审计游标与当前台账比:事件数不足、 或第 N 条的哈希对不上 → 抛,且两边的数都写出来。对不上只有两种可能:台账被截断, 或者这份快照来自另一本——一份来自别的台账的包,长得与真的一模一样。
不回填、不重算,并加了一条判据把它钉住,免得后面几轮顺手"优化"成"没有就重算 一份"。
锁挡的是"两个写的人撞上";读的人不拿锁,靠的是原子换上——判据钉住"换上之前 最终目录里一个字节都没有"。
CI 只选真错(F,E9,W,E7)。本候选另按改动行清了更宽的规则集:修掉 SIM105、 B905(zip(..., strict=True),配变异判据)、I001、PLR2004;明确排除不在 改动行上的 flow.py ISC004(P0 引入)与 UP037(P1 引入)、以及 tests/ 里 P6 引入的 FURB192——按"不做全仓清理"不动。中文标点、测试里的 assert、无类型 标注这些全仓性的写法一律不碰:逐条"修正"只会让新代码与周围不一致。
无迁移(快照格式未变)。门禁新增 20 条,16 刀变异逐一转红——其中三刀第一版没红: NUL 那道被 JSON 解析先挡了、DXF 的 EOF 那道被 ENDSEC 先挡了、清残骸那道的用例 在建目录之前就抛了;各补了针对性用例后红。按指令本轮不跑全量:最小红灯 42 → 直接受影响 216 全绿。
P5 报告里列的第一条未覆盖边界:导出在同一个进程里完整,重启之后就残了。工程师 手上那份交付包因此有两种命运,而他分不出是哪一种——热的时候完整,冷的时候 少一半。
.metalmaster/flow/pkg/<账号>/<job_id>/:snapshot.json(身份、来源指纹、状态、 文件索引、写快照那一刻的审计游标)+ files/ 下的逐字节原文。
账号只从会话来;包号与文件名遇到 ../绝对路径/空名一律抛,不清洗后放行 ——清洗过的名字与原名对不上,索引就假了。盘上用序号+安全名,原名留在索引里: 包里的名字含目录(sheets/cut-01.dxf),直接落盘就是一条现成的穿越。
用产品代码那条路重算 MachineJob.content_hash,必须与快照里记的一致。对不上 就说明这份快照与台账讲的不是同一份包——那时给出文件比不给危险得多:一份 看起来完整、其实对不上账的交付包,会被当成"就是它"。
坏文件 / 缺文件 / 快照截断 / 非中立格式 / 超限 / 自称可执行,逐条都抛, 不降级不跳过。逐文件那道 sha256 与总校验看似重复,但它说得出是哪一份—— 判据因此盯住消息有没有指名道姓(第一版只判"抛没抛",那道拆掉也不红)。
同一来源必然同一 job_id:存过且一样 → 盘上字节不动;存过而不一样 → 抛。 没有快照就是没有,导出照实说,绝不重算一份冒充原包——重算出来的可能字节 相同也可能不同,而"可能"两个字在这里就不可接受。
attach_package 落快照失败就不记这一笔(与 P1"先落盘再改内存"同一条纪律)。
既有 .jsonl 台账格式一个字节没动,无迁移;回滚时快照目录留着不删。
门禁新增 27 条(快照 25、服务 +2),14 刀变异逐一转红。落盘模型新增了一棵目录树, 所以冻结前跑了一次串行全量。
P4 那一页带不走:工程师要交给车间、附进邮件、留档,只能截图;而且只能按报价号 查,手上拿着一张工单、一个任务号、或者"3 号激光机今天跑了什么"就无从查起。
中立包 manifest、来源指纹、逐文件 sha256、阻断原因、审计游标。
游标(链头 + 事件数 + 最后一条事件的 seq/哈希)回答"这份导出是哪一刻的"。 没有它,两份不同时刻的导出摆在一起没人分得清谁新——而它们的内容可能只差一条 回执。同一游标必然导出同样的字节。
包里的 DXF 是设备包里已经产出过的那几份原样搬,判据逐字节比过:新生成一遍 就可能与设备包不一致,而两份都"看起来对",直到有人拿其中一份去开机。
厂商专用扩展名一律抛,不是过滤掉——过滤掉等于"我帮你悄悄拿走了一个你以为 在里面的东西"。导出是最容易被当成"能直接用"的那个环节:一个躺在下载目录里、 名字像那么回事的文件,没人会再去翻它是从哪儿来的。白名单而不是黑名单; 任何 executable=True 的包也拒绝。
有红旗或还有活卡着 → releasable: false,理由逐条在清单里。
按订单 / 任务 / 设备,三选一(多给少给都抛)。按设备那一路从审计事件读——派发 台账不在盘上,重启之后仍要答得上"昨天那台机器跑了什么"。软目标只用来排序展示, 不过滤:它一旦能过滤,反查就会漏掉东西,而漏掉的那条恰恰是人在找的那条。
导出与反查前后链头一动不动(判据盯这一条),所以"再导一次"没有副作用。
门禁新增 27 条(导出 19、服务 +8),13 刀变异逐一转红——其中一刀第一版没红: 台账里只有一台设备时,"按设备筛"那一刀拆掉也不会红,补了第二台设备之后红。 本轮按指令不跑全量:最小红灯先写(先红后绿)→ 受影响模块 347 全绿;证据重跑 逐字节一致。
审计照出来的缺口:只读面有七张表,但四张表各列各的,谁从谁来要人对着 id 拼; 设备包与回执压根没有——Ledger.packages 与 Dispatcher 都只活在内存里, 重放之后是空的。
新的 delivery.trace 把包状态、回执状态全部从审计事件读。审计台账本来就是 那份"事后还能拿出来的证据",而设备包是确定性重算出来的——让交付面只相信留过痕 的东西,它就不会在重启之后讲一个内存里才成立的故事。判据把冷热两次(重放前后) 的结果逐字段比过。
实体与状态从 flow 对象读,派生关系从审计事件读,provenance 判定调 constraints ——这一层一条状态机规则都不重写。
PROVENANCE_MISMATCH 与 PROCESS_PARAMS_ABSENT 在页面最上面标红。为了让第一面 旗真的挡得住,补了一条产品行为:dispatch_task 此前只在挂包那一刻查过 provenance,挂包之后报价改版、订单换绑,那条活照样发得出去——而中间隔着的那段 时间正是最容易出事的。现在下发前再跑一遍。
且只对还没发出去的任务算:包在 ACK 之后是 ACKNOWLEDGED,对做完的活按 "不是 RELEASED"判就会给出刺眼的假红——这条判据就是这么写出来的。
/flow/trace.json?quote=<id> 与 /flow/trace?quote=<id>,都在 MM_FLOW 后面、 默认 404 且说明原因;页面上没有任何能改状态的控件。
门禁新增 27 条(交付面 18、服务 +9),13 刀变异逐一转红——其中一刀第一版没红 (冷热两次的包哈希"都是空串也相等"),补断言钉死它就是那份包的真哈希之后红。 本轮按指令不跑全量:最小红灯 18 → 受影响模块 320 全绿;证据重跑逐字节一致。
审计现状照出来的缺口:链条只在"报价 → 订单"那一跳立住了。后面三跳全是空的 ——路线绑的修订号没人跟订单行对过号;零件不在订单里也能排任务、数量超订单也能 排;挂设备包只比"类型对不对",包里那份几何是哪一版根本没人看。
一张按 rev-A 报的价,可以派出按 rev-B 的几何做的活,全程没有一处会红。
OrderLine 补文件哈希与展开几何哈希,Order 补逐组排样指纹,CutJob 补排样 指纹并进包的 source_ids。order_from_quote 是唯一产地——手抄一遍哈希,迟早 抄错一次,而那次不会有任何地方报错。
逐组而不是合成指纹:一份报价可能跨好几组料,而一份设备包只覆盖其中一组的一张 板。第一版存的是合成的,真语料一跑就红——那次红得对。
零件不在订单、路线绑了另一版图/另一个零件、数量超订、包里没这件、包的零件版本 /展开几何/排样对不上。过不去就抛,不给"警告后放行"。
数量按 (零件, 工序号) 算:一个零件三道工序各一条任务、各是全量,按零件汇总会把 三道当成三倍产量,一上来就假红——假红比不红更糟,它会让人把判据关掉。
修订号与几何哈希两条都查:修订号不含展开几何,几何算法改了它可能没变。
比哈希那几条两边都有值时才比:升一次级把历史订单全判成违规,是这层能犯的 最蠢的错。
rank_tasks 的输出必须是输入的一个排列。软目标一旦能过滤,它就成了一条没写在 硬约束表里的硬约束,而那种约束没人查得出来。平手按 task_id 收尾(排序必须确定); 没填交期的排在后面而不是"很早";不认识的目标键直接抛。
红的是夹具不是产品:报价行、路线、设备包此前各自编了一个修订号,三份夹具 互相对不上号——而在这之前没有任何一处会去比它们,于是那套夹具"看起来"一直是 对的。现在它们共用同一份零件身份。
门禁新增 28 条,18 刀变异逐一转红。本轮按指令不跑全量:最小红灯 28 → 受影响模块 293 全绿;证据重跑逐字节一致。
在这之前,报价版本只能手填 JSON。页面上那个数与台账里那个数,是两次各自算 出来的——"这个价是按哪一版图、哪一次排样、哪一版算法报的"没有任何东西绑住。
草案自带:输入 STEP 的文件哈希与零件修订号、展开几何哈希、排样指纹、 算法版本、费率名、配置快照。指纹由位姿 + 料 + 参数 + 每件展开几何哈希算出来 ——换板材、换间距、换几何,它就变(三样各有判据)。
切割总长、穿孔次数、折弯道数、翻面次数、密度、毛坯,全部从 report.quote_inputs 与 FlatPattern 原样搬,判据逐字段核对相等。重算一遍 就是同一件事两套实现,而两边都会"看起来对"(切割明细与周长曾差过 4.4 倍)。
利用率只在组级:一张板上排着好几种件,把"这张板用了 62%"写到某一行上, 读的人会以为那是这一件的利用率。
报价照出(按工位费率结算,气压与焦点不进这个口径),但草案里记一条 PROCESS_PARAMS_ABSENT。同一件事让下游的设备包进不了 RELEASED——两头由 一条跨层判据钉着,不然它就只是一句好看的提示。
真正挡 ISSUED 的仍是 blockers:算不出总价、有件排不下、费率表缺工位。
revise_quote 注册新对象 + 把基版置 SUPERSEDED,一条命令两条事件。改掉基版的 话,"当时报的是多少"就没了,而那恰恰是以后要拿出来的那个数。
只让改单价与折扣,每条都要写理由。数量不给改:改数量就该重排一次,而重排 出来的是另一份排样——硬把新数量塞进旧排样的来源里,那份 sources 就成了假的。 基版是 ACCEPTED 时拒绝:客户认了的那版是冻结的。
/flow/draft(显式建草案,表单与 /nest 同一份)与白名单命令 revise_quote。 显式:不在 /nest 里顺手写一条台账——"我只是看了看排样"和"我出了一版报价" 必须是两个动作。派发那三个仍不在白名单里,MM_FLOW 仍默认关闭。
新字段全部带缺省,且 as_dict() 里空值不写出去:P1 落在盘上的行没有这些 键,写出去的话同一个对象在新旧两版里的内容哈希就不一样,回读时那道"对象体与 事件里记的哈希"会全线报错。判据拿一行手写的 P1 格式当靶子,另一条反向判据核对 "真填了新字段哈希就得变"。迁移无,回滚是取消开关。
门禁新增 36 条(草案 23、服务 +11、存储 +2),16 刀变异逐一转红——其中两刀第一版 没红(改版审计那刀选错了变异点、行级来源哈希没人核),补判据后红。
生意侧那条链此前只活在内存里,进程一停全没了——而审计台账的全部意义就是 "事后还能拿出来的证据"。
.metalmaster/flow/<账号>.jsonl,一行一次状态变化。整份重写会让"追加式"只剩 一句承诺:重写时少写几行谁也看不出来,那与一本可以随手撕页的账簿没有区别。
事件与对象体写在同一行:审计事件只带 content_hash,重放还原不出对象内容; 分两处写就会有"事件写了、快照没写"的中间态,而那个中间态看起来完全正常。 合成一行之后,崩在半路只会留下一行能被认出来的残行。
先落盘再改内存:写盘失败时内存一个字节没动。反过来会留下"内存里有、盘上 没有"的一条,重启回读时它就凭空消失了——盘上多一条是安全方向,内存多一条不是。
回读逐行核三样,其中一样是对象体与事件里那个哈希相符:只验链的话, 有人可以把订单行数量从 10 改成 1000,链照样自洽。任何一处对不上就拒绝加载 并指出行号——一本验不过的台账不该被当成证据。
Ledger.from_dict 删了。它与新的存储层是同一件事两套序列化——本仓最贵的那类 bug 的标准起手式(两边都会"看起来对",直到某天只有一边跟上了字段变化)。 as_dict() 留着,但它是投影,文件名也改成了 flow-snapshot.json。
追加前 flock 独占、拿到锁之后再重读链头(等锁那段时间里别人可能写过), 不一致就抛并带上两个链头。HTTP 层收 expected_head,对不上回 409 并把当前 链头给回去——只说"冲突了"等于让调用方自己猜该重读什么。
不是"后写的赢",也不是"合并":两个人各自基于旧状态推进同一张订单,合并出来的 东西没有任何一方想要。
/flow(只读页,没有任何能改状态的控件)、/flow.json、/flow/cmd。 命令白名单 16 条,不含 attach_package/dispatch_task/apply_receipt ——那三个通向设备,而设备协议还在阻塞清单上;请求它们回 403 并说明原因。
读坏了不给空台账:那样页面会显示"这个账号还没有单子",而真相是那本账在 盘上、只是读不出来。
MM_FLOW=1,默认关闭只认显式的 1。关着时三条路由 404,且在响应里写明是开关没开——404 什么都不说, 运维只能靠猜。
迁移:无(全新路径,既有文件一个字节不动)。回滚:取消开关,盘上的台账留着 不读——回滚不该销毁证据。前滚:行首带 "v": 1,遇到不认识的版本拒绝加载。
门禁新增 42 条(存储 16、服务 26),12 刀变异逐一转红。
报价版本 → 订单 → 工艺路线 → 生产任务 → 设备包 → 回执。设备包与回执原样复用 上一段的对象,不另起一套。
日志是给人看的,出了事去翻;台账是证据,要能回答"这批货凭什么算做完了"。 所以四个对象的 state 是只读属性,唯一的推进口子要拿着一条已经写进台账的 事件——"改了状态却没留痕"在构造上不可能,不是靠谁记得记一笔。
台账按哈希链串起来,且每条把写下那一刻算出的哈希也存下来:只靠 prev_hash 的话,改第 k 条要等第 k+1 条才对不上,而改最后一条根本抓不到。现在改哪条就 指哪条。砍掉末尾几条仍然查不出来(链内部自洽),这是哈希链的固有限制,所以 verify(expect_head=...) 收一个外部锚点,并把这条限制本身写成了判据——免得 有人以为 verify() 通过就等于台账完整。
命令带 command_id:重放不写第二条、不改状态、把上次的结果端回来;同一个号 配不同载荷 → 抛;没有命令号 → 抛(缺省放行等于把重放保护变成"看心情")。 跃迁走白名单(漏写一条黑名单等于放行),非法跳转抛异常(返回码会被人忘了看), 被拒的跃迁在台账上不留半条。
nestjob 那句"半张报价单比没有报价单更危险" 本来只管页面不印总价,现在是状态机上的一道闸。ACCEPTED/RUNNING/DONE/FAILED 没有人工入口,而且那道检查排在白名单之前 ——从任何状态硬跳都被同一句话挡住。一个人工能直接点成完工的状态机,报出来的 产量就只是一句话。
回执带 source 与 simulated,由适配器盖章,不从设备端那份 JSON 里读: 让设备端自称"我不是仿真的",等于没有这道闸。source 默认空串,也就是默认 不认。核对只有一处(dispatch._check_provenance),上层不再判一遍。
下料/折弯/卷圆/攻丝/二次加工从分析报告推,每道标 derived_from_geometry, 整条路线挂着"工序序列未经确认""折弯序未做碰撞校验""攻丝孔是按孔径猜的候选" "去毛刺、热处理、表面处理、装配几何里看不见"。批准要写理由——人替它背书, 背书要留话。
docs/evidence/neutral-mfg-package/flow-report.md + flow-ledger.json:真样本 从报价走到完工,两条路各一遍——挂柏楚包的那条下发被拒(理由逐条印着), 挂仿真包的那条走完全程,每条回执都标着 source=simulator、simulated=true。 台账 27 条,读回来还验得过。
不落盘、不进服务端、不碰真实设备与线上数据;序列化往返做了,接盘是下一步。
门禁 88 条(审计 18、流程 47、回执出处 7、真样本 16),关键判据逐刀变异验证过 ——其中两刀第一版没红(元判据的等式对"把记录记到别处"不敏感、序号连续那道被 哈希链顶掉),补了针对性判据后红。
到 nestdxf 为止我们出的是几何交换文件。车间里常有人把它叫成"机器程序", 但它没有工艺参数、没有引入引出、没有机型、没有回执——拿到机床前还要在 CypNest / Delem 上被人过一遍手。
这一版把链路拆成五段,每段各自成立、各自可验:
PartDefinition → FlatPattern → CutJob / BendJob → MachineJob → MachineReceipt
柏楚的 .lxds/.nrp、Delem/Cybelec 的控制器程序,格式都不公开。生成一个 "看着像"的文件出来,是这个项目能犯的最严重的错误:它能生成、能上传、能显示 "已就绪",直到有人拿它去开机。
所以两个真适配器出的是离线导入包——几何交换文件 + 清单 + 图层/工艺映射 + 校验报告 + 人工导入清单,并在能力自述里逐条列出它做不了什么。 Capabilities.executable_program 全部为 False,一条判据盯着它。
ValidationReport.ok 要求查过至少一项:findings: [] 有两种读法 (全查过没问题 / 一条也没查),它们在 JSON 里长得一模一样,而后者意味着 这份包没人验过就发到车间了;MachineJob.release_block 的默认值是"挡住"——判据不能踩在缺省上, 默认放行的话,忘了调用检查的那条路径会一路绿到底;缺机型 / 缺材料 / 缺板厚 / 板放不下 / 厚度超范围 / 能力对不上,package() 直接拒绝,不出半份包——半份包看起来是可以拿去导入的。
micro_joints = () 有两种意思:"查过,不需要"和"根本没查"。所以凡是这一版 做不到的,都配一个具名状态:微连接与共边 not_evaluated、折弯序 geometric_order、碰撞 not_evaluated、后挡料 not_computed。页面与包内清单 照它出话,报告里也逐条写着。
真机器的幅面、板厚范围、控制器型号我们猜不出来。缺省那两台标着 source="demo",发布前检查里有一条专门挡它;工厂设置页上也直接写明。 真机型填在 .metalmaster/factory/<账号>.json 的 machines 里。
simulator 适配器 + 仿真机把 MachineJob → 派发 → ACK → MachineReceipt 走通, 于是重发幂等、回执重放、超时、取消、回写这五件事都立得住判据。两个真适配器的 dispatch() 直接抛——假机器跑通不等于真机器能跑。
docs/evidence/neutral-mfg-package/:真实样本 DYL-zhong-13.step 走完整条链的 两份包全部文件 + 端到端报告(每段的输入/输出/哈希/状态/未决项)。两份包都停在 DRAFT,各有三条发布阻塞写着名字。
阻塞清单(厂商格式、设备协议、真实机型与工艺参数)见 docs/manufacturing-package.md。
门禁 109 条(领域与状态机 21、适配器 46、派发 14、页面 18、真样本 10), 关键判据逐刀做过变异验证。
空板上所有朝向都落在 (0,0),所以每张板的第一件必然平手;谁赢了这一手, 整张板的栅格方向就定了。原先一律"转角小的赢",于是 0° 永远赢。
ets01835(409×547,几乎是个矩形) 每板 8 件,利用率 55.8% 同一件强制 90° 每板 10 件,利用率 69.7%
一个平手规则差了四分之一的料。而这是报价里最大的一项。
真轮廓不是矩形:照那个规则排,DYL-zhong-13 反而从每板 37 件掉到 35 件 (89.7% → 84.8%)。代理指标会指错方向。
所以不猜:两种平手规则各排一遍,按结果留省料的那个。实测(1250×2500, 间距 4、边距 10,排满一张):
ets01835-dimpled-csk 55.8% → 69.7% ( 8 → 10 件/板) AGS000190-01-flanged-plate 70.6% → 82.4% (18 → 21 件/板) 卷材混排 78.7% → 79.4% DYL-zhong-13 89.7% → 89.7% (平铺偏好会让它退到 84.8%,被挡下) 其余五件 不变 合计 +26.4 个百分点,无一回退
代价是放置阶段跑两遍(掩码共用,所以远小于两倍);1500 件的绊线判据实测 3s→6s, 预算 30s。
其实是"你只要 8 件,而这张板能放 156 件"。车间买的是整张钢板,所以再排 148 件,那 148 件的材料几乎是白送的——每件材料费 ¥26.68 → ¥1.37,19 倍。
同一个事实,一种说法让人以为出了错,另一种直接告诉他该怎么办。结果页上现在 两句都有。
容量能白拿就白拿:批次已经跨板时第一张必然是排满的,上面几件就是每板容量, 不必再排一遍——而"再排一遍"恰恰在大批次上最贵,那时答案却已经在手里。
判据要求这个容量真排得下:拿面积除出来的数会高估,页面照它说"再排 763 件 不用多买料",人下单之后才发现要多买一张板。变异验证照出过:光查"数够大", 除法版照样绿。
门禁 11 条(排样朝向 2、还能再排多少 5+2、料库对号沿用),变异逐刀全红。
用户:"画图的部分应该符合标准,清晰美观,后续能够直接生成匹配钣金设备的输入 格式。""包括对于整体物料的设置,应该跟所在工厂有关,可以独立配置好。"
to_dxf 把每块面板的 loop、每条折弯条带各自当闭合轮廓写进 OUTLINE。一件 两折的零件于是变成五个闭合区域——激光照着切,件从折弯处断开,而折弯线正是那些 多出来的刀路之一。
"哪些边要真切一刀"这件事早就算过:切割总周长就是 _free_segments(_outer_rings) 的和。DXF 只是没用它,自己另走了一套。现在从那儿取,于是机床走的线与报价算的 线在构造上是同一条(判据逐毫米对它们)。
图纸上的展开视图犯的是同一处病,而且错得更绕:面板闭环画成粗实线(凭空多出几条 "边"正压在折弯线上),整条折弯条带四条边画成点画线(其中两条横边是真正的毛坯 边)。两处都错,方向还相反。现在与 DXF 吃同一份 cut_entities。
$INSUNITS=4):坐标是裸数字,不声明单位,导入方按自己的 默认猜——猜成英寸就是 25.4 倍,一块 1250 的板变 31750;一次生产跑的是一整张板,所以"机器的输入"指的是排好样的那张。每张板的题头上给 下载,走「同一份表单再交一次」——排样是确定性的,不必把结果存下来,也就没有 "存的那份和页面上这份不是同一次"的问题。
刀路、位姿、格式各自只有一个产地:develop.cut_entities、 nest.placement_transform、dxf 模块。
此前板材尺寸在一个元组里、费率在 rates.DEMO、间距边距在 Params 的默认值。 一个厂换了板材规格,得改代码——而这套东西的用途恰恰是给不同工厂各自报价。
现在一个账号一份 JSON:库存板材(牌号 + 厚度 + 幅面是一条)、材料单价、工位 费率、工艺默认值。空牌号 = 删掉那一条(不另做删除按钮,"改"和"删"是同一个 动作);新加牌号立刻在单价表里显形;档案坏了退回缺省并明说,不是 500。
顺带修好一处并排摆着的矛盾:小计按工厂费率算,账单上每一行的单价与金额却写死 演示费率——报价员照着行金额复核,怎么加都对不上小计。
<dt>,页面上根本没这种标记,两边都抓到空集合;assert out 把它拦下的。判据自己也要有 自检。还有一个假数字:count("LINE") == 52 里一半在数字母——图层名 "OUTLINE" 自己 含 "LINE",24 条线贡献 24 个实体关键字外加 24 个层名,加 4 恰好凑成 52。
端到端实测时照出来的:料库里只有 3 mm 的 Q345、件是 1.5 mm 的,排样照排、报价 照出、板号写着 Q345-1.5-01——全程没有一处把这两个数对过号(Plate 只有 宽和高,厚度从件上来,选的那块料的厚度谁也没看)。照这份单子备料,仓库里根本 没有那个规格。
现在对不上就明说,并说清本厂这个牌号有的是哪些厚度。不硬拦:从别处调一块 料是常事,拦住比说一声糟。
上线后在生产上真下了一份 DXF 来验,全部对上(整张板刀路 16265.2 mm = 单件 ×8,差 0.00 mm)。但顺带照出「折弯线不许落在切割图层」那一刀写成了"一段都不许 重叠"——而止裂槽的边本来就与折弯线共线:ets01835 上 177 mm 的折弯线末端被 刀路盖住 2.4 mm。合成 fixture 里没有止裂槽,所以它一路绿着,等真件来了才会 假红。
改判覆盖比例(整条被切 = 100%,止裂槽是百分之几),并加一条跑真语料的 门禁——真实来料上实测 0.0% / 0.1% / 1.4%,离 20% 的界很远。
门禁 35 条(DXF 标准 8+1、整张板 9、工厂档案 10+5、账单一致 1、料库对号 1、 两条路对等 4),变异逐刀全红。
用户:"用户直接上传的零部件和装配体的零部件,各项体验应该一致。" (2026-08-15 也说过同一件事。)
把两种零件页逐项比了一遍——DXF、工程图、原始 STEP、JSON、按来源分的标签、 重算、回目录、回装配,都一样。差的只有一样:
装配体的零件页没有「上一件 / 下一件」。 而一台设备有 96 种零件,逐个看却 翻不了页,只能退回装配页再点下一个——最该有翻页的地方反而没有。
根因是槽位只在文件级导航条里(PARTNAV_SLOT),零件级那条根本没有它, 所以填也填不进去。
_partnav_bar(items, i):文件清单与装配体零件清单共用一个 产地。两种清单差的只是"下一件的链接怎么拼",别的一模一样(j/k 快捷键、 第 N/M 件、首尾去箭头)。门禁 2 条(设备内翻页、两种零件页逐项对等),变异 3 刀全红。
第一版按"套料 vs 按毛坯矩形下料"取两头,实测只差 ±1%——等于没有区间。 两个原因:那两种排法本来就接近;而这几个件的材料费只有几分钱,淹没在工时里。
真正让"板子利用率比较低"的是批量小:
批量 1 件 → 用 1 张 → 每件材料 ¥206.06 批量 10 件 → 用 1 张 → 每件材料 ¥ 20.61 批量 200 件 → 用 1 张 → 每件材料 ¥ 1.03 批量 1080 件 → 用 1 张 → 每件材料 ¥ 0.19 ← 排满
给的不是两个数,而是一张按批量列的小表(1 / 小批量 / 50 / 200 / 排满), 读的人直接找自己的批量,不必信我们选的那两个点。并且写明前提:"假设这一批 单独下料、不与别的活拼板"——真车间常把小单拼进别人的板里分摊,不写出来那个 上限会被当成乱报。前提印在小表底下,不只藏在卡片的问号里:读表的人正是在 对着数字做决定的那个人。
一张板排得下几个是真排出来的。判据也从"比数值"改成"拿那个数真排一遍, 必须一张装得下"——比数值抓不住,两者在有些件上恰好接近(变异验证两次照出来的)。
a8077e2 给每个 <h2> 加了 data-tab,而三条判据的正则贴着 > 抓; 另有一条把我写在 CSS 注释里的"默认全部显示"当成了页面上真有那个按钮。
跳转条那条的意图也变了:分标签之后每个标签各有一条跳转条、各管各的那几节, 所以判"每个锚点都被它所在标签那条覆盖",而不是"有一条覆盖全部"——后者在分标签 之后本来就不成立,硬判只会逼人把标签拆掉。
这次破例跑了全量:改动落在 htmlreport.py(报告的公共渲染层),影响面远超 页面那几个文件——CI 红的三条正是漏跑的 test_webapp.py / test_form_factor.py。 选测试文件应当按改动面,不按"感觉像不像 UI"。全量 2026 passed。
用户拍板的两件之一。过程里两次自我纠正,都记在这儿。
我此前说"四个语料件全都打不过朴素矩形网格"(方件差 21%、托盘 12%)—— 那是错的,来自错的标尺。我用"每件平均占地"去比,而它把最后一排没排满的 空地摊到了每件头上,矩形网格同样会有那块空地。换成比"同样件数下用掉多长 卷料"(网格自己也算得出那个数)之后,方件/长条/托盘本来就和网格一样密。
真正吃亏的只有凹形件:L 形 686 mm vs 网格 622 mm。
三种写法都量了(1250 卷宽、每种 120 件):
件 不下滑 下+左 只往下 L 形 686 517 494 混排 610 628 ←倒退 611 方件 156 156 156 (耗时 0.05 → 0.44 → 0.07s)
往左那一下得不偿失:它最激进地去填低洼,把后面更大的件本该落的地方占掉 ——混排倒退 3%,而混排才是真实排样的样子;时间上也是它最贵(方件一格不省 却慢 9 倍)。去掉之后 L 形反而更省。
"先跳 64 格再减半"直觉上能把 O(距离) 降成 O(log 距离),实测 3.22s vs 3.20s,没有差别:候选角点本来就贴着已有件,掉不了多远。先前那 235 倍的 膨胀是"下+左"来回滚造成的。没量出好处的复杂度删掉,并把这次测量写进 注释——别凭直觉再加一遍。
门禁 3 条构成上下夹:不许比矩形网格费(防退化)、凹形件必须真省下料 (≤550 mm,真值 494)、混排不许被搞差(≤620 mm)。变异 3 刀,其中 「加回往左滑」会让混排那条红——正是它该拦的。
截图量的:390px 宽上 6 列硬塞进表格,零件名被压成一条——Describe Document - AGS001938-04, FISHPLATE LEFT, SEI, 04.1 - AGS001938-04-04.stp 断成 8 行。
data-l 做标签 成对显示(牌号 / 板厚 / 毛坯 / 数量)。窄屏上"对齐成列"本来就没有意义—— 一屏放不下两列数。名字从 8 行降到 3 行。:first-child,被一并绝对定位后整行塌成一条空灰条(第一版就是, 截图看出来的)。现在是完整的卡:名字 + 统计 + 通栏按钮。门禁 2 条,变异 3 刀全红。
判据自己栽了两次,都记在注释里:.replace(" ", "") 只对一边去空格 (今天第二次),以及改变量名漏了一处。
用户 2026-08-25:"点击开始排样之后没有反应。"实测 5 个子件 229 秒,96 件 一小时量级——它其实在跑,只是页面一点提示都没有。
慢的不是排样内核(5400 件 36 秒),是展开几何:它只活在内存缓存里 (_Cached.developed),盘上存的是报告字典、没有展开几何,所以重启之后即便 "算过"也要重跑一遍。
_start_job,带并发上限与锁文件)——不另起一套任务机制。mass = volume × ρ, 而体积报告里已经有了。此前材料进缓存键,键一换就不命中,白等几十秒去拿 一份一模一样的展开轮廓。现在拿不带材料的那份几何,牌号在报价层注入 (_with_material,改的是副本——原报告是缓存里那一份,动它就等于把 别处看到的数一起改了)。get_part 换成会炸的:只要它被调到,就说明又内联算上了。门禁 4 条,变异 7 刀全红。其中两刀原先是绿的,都补上了判据: 「不起后台任务只干等」(那一页会一直重试到天荒地老,比"点了没反应"更糟, 因为它看起来在干活)、「"在缓存里"当成"排得了样"」(只有报告没有展开几何 时会放行,然后在请求里重算——正是这条路要堵的东西)。
用户 2026-08-25:"你下面的分析问题太多了,看不清楚到底这些分析的结果有哪些 价值,特别花哨,但是不知道从何而来。我倾向于把从 3D 和图纸来的信息,分别做成 两个核心的信息提取标签……然后应该再做一个 3D 与 2D 映射的标签。"
截图量的:详情页 6040px 一条直筒,十来节一样大平铺,没有主次;最该先看的 「标记」在最底下,而那一整墙红字又是全页视觉最重的东西。
parts.append("<h2>…"); 没匹配上的一律落进兜底标签——漏一节进错标签,总好过漏一节不见了。.tabon 开启切换。写死 display:none 的话,脚本一挂整份报告就只剩一节——与落地页 进场动效同一条纪律。#s7 这种)会自动切到它所在的标签。门禁 4 条,变异 5 刀全红(其中"新章节没兜底"那一刀会让整节消失,是最该守的)。
「图纸提取」要不要真做,是个需要决定的事:解析矢量 PDF 会引入本仓目前没有 的依赖,而"除几何内核外零依赖"是写在落地页上的承诺。
用户:"请把整体的 UI 优化一下(在 dev 上做),尤其增加了排版之后,整个交互问题 就比较多了。"
用真 Chrome 把页面截下来看,而不是读源码猜。五个问题里三个是控件/布局的 真 bug,不是"不好看":
stock_from_form 只看 plate_w/plate_h。 选 1000×2000 而下面写着 1250/2500,两个数互相打架,以谁为准没人说得出来。 控件是摆设这一类错,这是第四次(?asm=、fallback_code、这个下拉, 外加那条只判文字的入口判据)。改法是名字与行为对齐:它改叫「常用尺寸 (选了填进下面)」,只负责把数填进那两个框——板宽/板长才是唯一真值。<a class="btn"> 不像按钮:.btn 少了 text-decoration:none 与 display:inline-flex,链接带下划线、按行内文字排版——目录页上「排样」那个 入口就是这么变成一条下划线文字的。break-all 拦腰断字:Describe Document - AGS001938-04 断成 "…- AG / S001938-04",读的人得把两行拼起来才认得出是哪个件。改 anywhere 并给文件列一个下限宽度。另加一条通用门禁:页面上不许出现字面的 **——源码注释里到处用 Markdown 强调,顺手写进 HTML 字符串就会原样显示成两个星号(排样页顶部那句就漏出去过)。
门禁 +6 条。按用户 2026-08-25 的要求只跑相关文件(254 项绿),不跑全量。
用户:"太丑了,根目录下面筛选那一行有冗余,然后一选择具体的某个钣金件,就会 把排样放到下一行"、"要确保列表页和装配体页(零部件的列表页)交互都是一样的"。
真浏览器量出来的:条高 102px、两行,1280/1440/1680 全断;而上一轮我那个 "成组换行"更糟——整组按钮永远待在第二行,未选中时就掉下去了。容器只有 1120px,内容却要 280(搜索)+260(状态)+125(列)+70(计数)+361(按钮组)+间距 ≈ 1146。
减内容,不调 flex:「未选择文件」不写了(首屏是服务端渲染的,光靠 JS 清空 不够)、「排样与报价」→「排样」、「导出 CSV」→「CSV」,全称进 title。 改后 56px、一行(1070/1120)。
表格(parts_table)与工具条(list_toolbar)早已共用,但行为分别写在 _CATALOG_JS 与 asmview 里——于是装配页的零部件清单没有 Shift 连选、也不能 点整行进详情。用户 2026-08-15 就说过"并没有统一起来所有的 UI",这次又问了 一次。把整行可点 + Shift 连选 + 勾选回调搬进两页都加载的 GRID_JS,每页自己 的计数由 window.__gridRefresh 回调(那个钩子两页本来就都设了)。
追这件事时挖出来的:装配页把它的五个脚本块全包在 document.addEventListener('DOMContentLoaded', …) 里,而 DOM 桩把 document.addEventListener 写成了空操作——那些代码一行都没跑过, 而"装配页脚本不抛异常"那条判据照样绿。空绿比没有判据更糟。
桩补齐:DOMContentLoaded 存下来跑完脚本再放;后代选择器按最后一节取 (#chips .chip → .chip,此前一律空数组,于是"状态筛选没绑"也是绿的); onclick= 也算绑上 click;canvas 的 getContext、ResizeObserver、 sessionStorage、devicePixelRatio 等一族全局。放宽处都写明了——放宽的 地方就是它可能替代码遮丑的地方。
门禁 +5 条(两张页面的交互对等、筛选框都能筛、工具条不写废话、短标签留 title、 装配页也验),变异 8 刀全红。
用户连报四条:"我只点击了一个零部件,为什么进去之后是不同的一堆装配体里面的 零部件"、"缺少返回按钮"、"检索栏串行"、"点击开始排样之后没有反应",以及 "请设计得更加美观大方一些,拆图中的文字可以去掉,因为上面有"。
前三条是同一个根因:我把装配体的子件平铺进了全量清单。一台 96 种零件的 设备就是 96 行,从目录页点进来看到的就是"一堆认不出出处的行";那张表几百行 还没有筛选;工具条又被新加的按钮挤到两行(1280 宽实测:导出 Excel 独占第二行)。
?asm= 才摊开, 摊开的那一版里别家的零件一个都不出现。htmlreport._CSS(两张清单共用的那一份)——第一版放进了只有目录页 吃的 _SHELL_CSS,是既有门禁 test_the_assembly_page_carries_styles_for_ everything_it_uses 逮到的:结构共用、样式没跟过去,本仓反复犯的四类错之一。"点了没反应"其实是在跑:实测 5 个子件 229 秒。慢的不是排样内核(5400 件 36 秒),是没按当前算法算过的件要重算几何,一件几十秒。现在点下去立刻出 遮罩,带已用时间与一句实话("N 种件,其中 M 种要重算几何")。判据真派发一次 submit 看遮罩有没有打开——只查 HTML 里有没有那几个字是判不出来的,处理器没挂 上时那几个字照样在。仍是同步请求,96 件要一小时——丢进任务队列边算边出进度 是下一轮的事,记一笔欠账。
图上不再挤字:只剩左上角一个极淡的等宽板号。尺寸/件数/利用率全归网页题头 ——挤在图里的那一行跟着 SVG 缩放、字号不可控,正是"花哨看不清"的由来。板号留着 是因为图会离开页面(打印、导出、发给车间),总得认得出是哪一张。
轻量化:卡片去掉重阴影(一屏好几块各自投影就"花");缩略图选中态改成一条 左边线 + 淡底,不再整框加粗;板号 16px 粗体 → 13px 等宽(它是标识不是标题); 组标题降级加细分隔线,层级靠结构不靠字号堆;总价改成一条横条。
门禁 +9 条,变异 17 刀全红。DOM 桩补了两个盲区:定时器改空操作(页面里的自续 计时器会让 node 永不退出,判据第一版挂死两分钟),classList.add 改成记录 (否则"遮罩到底开没开"判不出来)。全量 1998 passed。
用户跑完排样看到"总价不给:下面有分组缺了材料、费率,或有件排不下"。
不是排样出错:选件页右侧那个「材料牌号(件上没标时用)」下拉, fallback_code 只存在于 HTML 里,服务端一个地方都没读。用户选了牌号, 它被静默丢掉 → 没有密度 → 没有质量 → 材料费给不出 → 按"给不出就别给"的规矩, 总价整个不给。
界面承诺了一件它不做的事,还不吭声——这比"没做"更糟:人只会以为是自己 别的地方选错了。这是同一类错的第三次(?asm= 那次一模一样:链接做好了、 收的一端没写)。
_fallback_material 单独判:夹在 _nest_run 里,只有语料恰好有带牌号的件时才走得到那一支, 而那条件不成立时变异是绿的。顺带发现:materials.guess_code()(从文件名/文本推断牌号)定义了却从没被 任何地方调用,所以牌号目前只可能来自 STEP 文本里的材料标称——绝大多数图纸 没有。要不要让它按文件名推断,另议。
门禁 +4 条,变异 6 刀全红。全量 1992 passed。
用户从装配页点进排样,看到的是"没找到装配体 5my-zp-d00_asm_asm.stp 的零件"。
根因不是参数没对上,是排样页只认当前算法算的那份结果。 一次部署改了代码 指纹,盘上的结果全被打成"还没解析",于是没有一行带装配体标识,?asm= 自然 筛不到。而那台设备的数据好好躺在盘上:
严格口径 → None 允许旧口径 → is_assembly=True,96 种零件,445 个实例
仓库对这件事早有定论,是我没照做(见 cached_part_report 的注释): "一次改动就把 105 个零件全变回未计算,而结果明明还在盘上……种数与件数是 结构性事实,不随算法变……旧结果照旧显示、标一下旧,要不要重算由人决定。"
allow_stale=True;store.get / get_part,该重算就重算, 旧数只用来把清单列出来;判据用一个假 store 精确造出"严格没有、旧的有"这个情形,变异 5 刀全红。 全量 1988 passed。
用户:"排样的结果……现在看着还是有点杂乱。分页 / 信息罗列 / 板材编号", 以及"在装配体里面,我应该如何多选零部件然后计算排件"。
牌号-板厚-序号(如 Q235-2.0-03,nestdraw.plate_no 唯一产地)。 不用光秃秃的序号:一单常有好几组料,"第 1 张"对不上是哪种,而车间领料看的 就是牌号和厚度。编号同时印在网页题头与图里——图会被打印、导出,离开页面 之后只有印在图上的东西还认得出这是哪一张。list_toolbar 本体:总目录与装配零件目录同时有。此前塞在各页 自己传的 right= 里,装配页漏了整整一轮——而当时那条判据的措辞写着"入口没挂 在共用工具条上",实际只验了 catalog_page 的源码文本,判据在撒谎。/nest?asm=<文件>:只列这台设备的钣金件,默认全勾,另给「按装配里的数量 填入」「全选/全不选」「看全部文件」。此前链接带了 ?asm=,服务端那一端根本 没写——从一台 105 件的装配体点进来面对的是全量清单。两边各自的判据都是绿的: 链接判据验"有没有带参数",排样页判据验"能不能跑",没有人验这两段连不连得上。 新判据走整条:装配页 → 点链接 → 勾 → 跑 → 出图。querySelectorAll 原先一律返回空数组,所有按 class 选的代码都没被跑到——"缩略图没绑 click"那一刀因此是绿的。现在桩认 class 选择器,closest() 也返回桩元素而不是 null(真 DOM 里 .pick 就在 <tr> 里,返回 null 是桩在说谎)。用户:"我上传文件上不去,拖拽文件的时候它没有在框里面被选中,而是直接在浏览器里 把文件打开了……点击上传区域也没有任何反应。"
_CATALOG_JS 里 if(grid0) grid0.addEventListener(…) 引用了一个从来没有声明 过的 grid0(eb724bd 把筛选搬进 GRID_JS 时,声明留在了搬走的那一半里)。 裸引用未声明标识符会抛 ReferenceError——if(...) 这个守卫救不了自己 (要 typeof 才不抛)。
代价是这一行之后的全部代码都不执行:整行可点、Shift 连选、「解析全部」、 导出 CSV/Excel、以及上传。preventDefault 那几行和 drop.onclick 从来没挂上, 所以拖进去浏览器直接把文件当导航打开、点上传区毫无反应。
而 Python 那边一条判据都不会红:HTML 里该有的元素、该有的脚本文本一个不少。 空目录时更糟——传不上第一个文件,就是死锁。
tests/test_page_scripts.py:页面 JS 的门禁,把渲染出来的脚本喂进 node 真跑一遍(最小 DOM 桩,querySelector 只认这张页面上真有的 id)。判两件事: 跑的过程中有没有抛异常;该挂上的事件到底挂上了没有。preventDefault——只判 "事件挂上没有"挡不住"挂上了却不拦",而后者正是用户报的症状(变异验证照出来的)。grid0 声明(复现线上原样)、拖拽不 preventDefault、 点击不弹文件框、上传绑定前塞一个未声明引用。用户 04:19:"我现在 https://metalmaster.ymp1.yuanmu-ai.com/ 和 https://metalmaster-dev.ymp1.yuanmu-ai.com/ 都访问不了,显示 502。"
根因是 systemd 的 KillMode,不是 OOM。 metalmaster-deploy.service 是 Type=oneshot 且没写 KillMode,默认 control-group——oneshot 一结束, systemd 就把该 unit cgroup 里剩下的进程全部清理掉,而这个 oneshot 干的事 恰恰是起一个长期跑的服务。restart.sh 里的 setsid 只脱离会话,不脱离 cgroup,所以每部署一次就把刚起来的服务杀一次:
00:09:46 [生产] 已上线 e74fd939 → 几秒后被杀 01:30:29 [测试] 已上线 423610cc → 几秒后被杀 04:27:48 [测试] 已上线 d1ddf9f8 → 几秒后被杀
最难查的地方在于每一处记录都说成功了:restart.sh 的健康探测跑在 oneshot 结束之前,它看到的 200 是真的,只是活不过那一刻。日志上写着"已上线", 站点却是 502,两边 serve.log 都停在启动那一行、没有任何异常栈(进程是被信号 直接杀的)。手工重启的进程反而活得好好的——因为它们不在那个 cgroup 里。
KillMode=process(真修法)。判据读 unit 文件的生效指令,变异两刀 (去掉 / 改回 control-group)全红。deploy_one 里那句 [ "$now" = "$want" ] && return 0 从前 commit 没变就安静退出,根本不问进程 还在不在。现在也打一次 /welcome(公开静态页,不必带凭据、不会在审计日志里 刷假访问),不应答就拉起来并记一行。连打两次才判死;查不出来一律保持现状 (和 CI 那几条同一条纪律——检查坏了却每 3 分钟重启一次线上,比不检查还糟)。用户:"增加这个系统的 landing 页面……"之后:"增加钣金件的报价和在钢板或者 钢卷上的 layout 的功能。这里面应该可以上传多个钣金件或者是装配体的不同钣金 零部件,能够选择不同的钣金件和数量,然后做好排布问题。"
口径由用户拍板:真轮廓套料(不是矩形近似)、板材 + 卷材都要、 孔一律填实不套排、内置费率表直接出金额、从已上传文件勾选 + 手填数量。
metalmaster/nest.py 排样内核(纯函数、零依赖)。develop.flat_entities 取,不另开产地。展不开的件给 空轮廓、不猜——猜个包围盒塞进去,排样会照排、报价会照出,而那张图对不上 任何真实工件。metalmaster/rates.py + nestjob.py 批量报价。费率是纯数据、可整体替换; quoting.py 一行没动,仍是费率无关的("费率属于企业"那句口径保持成立, ERP 还能按原样消费它)。metalmaster/nestdraw.py 排样图:一张板一张 SVG,位置全部来自 NestResult,出图不自己算一遍;图上印的张号/利用率/用料长度一律现取。server/nesting.py 排样页(/nest,在登录闸门之后):勾件、填数量、 选料与工艺 → 每张板的图 + 用量表 + 报价单。未解析的件照样出行并写明原因 ——悄悄不显示,用户看到的是一张"完整"的清单,他会按它报价。Developed 的"复用分析实例、缺了才重建"在 app.py 里 逐字抄了两份,抽成 report.developed_of。排样要用第三次,先并成一处。文件.step#N)——用户要的原话是 "多个钣金件或者是装配体的不同钣金零部件",而装配体本身根本不是一块料。 子件下标与 /part?sub=N、/dxf?sub=N 是同一套,不另起编号:两套编号 本来就不同源,各算各的必然错位,而错位的表现是"图出了、利用率也有", 只是画的是另一个零件。行上写明"装配里 N 件"作提示,数量仍手填。1e999 → int(inf) → 500;数量没有上限, 填个十亿就是一次拒绝服务;一件都没选时印出 ¥0.00 的总价 (sum([]) 是 0.0——没人会把它读成"你什么都没选")。cached_part_report(与装配页、analyzed_parts 同源)。 静态检查的 F811 拦下来了。用户:"增加这个系统的 landing 页面,提升吸引力,增加 blog。"
server/landing.py(落地页 / 博客 / 更新日志)。pages.py 已 1385 行,那边是表格筛选导出、这边是首屏图示与文章排版,搅在一起改哪边都得 先翻过另一边。样式只叠计算包的设计令牌(htmlreport._CSS),不再继承登录后 界面那一套。.rvon 之下,并给不 支持 IntersectionObserver 的退路。写死在 CSS 里的话,脚本一挂就是整页白屏 ——内容全在 DOM 里,人什么也看不见。## 二级标题、有序列表、`` 代码块、> 引文、--- 分隔线、[链接]()(**协议白名单**,javascript: 不生成 <a>);上一篇/下一篇 导航、阅读时长、/blog/rss.xml` 订阅源。新增 4 篇(体积恒等式、「不适用」不是 0、顺序无关性、258 倍那一刀),共 6 篇。** 被粗体替换吃掉:metalmaster/**/*.py 渲染成 <code>metalmaster/<b>/*.py</code> </b>…——星号丢了,标签还交叉。 浏览器自己纠错,所以页面看着只是"少了两个星号"。修法是行内码先摘出来占位。Host 白名单正则用 $ 收尾:Python 的 $ 匹配结尾换行之前, a.com\n 会被当成合法域名——而换行正是往响应里注入的那把刀。改 \Z。%a/%b 跟随 locale,服务器上换个 LANG 就 吐中文星期几,订阅端整份解析失败。判据同时判格式与"实现里没有 strftime"—— 本机与 CI 都只装了 C locale,只留"换个 locale 跑一遍"那条等于没有判官。用户:"线上正式版本用现在的域名,dev 环境改成用 metalmaster-dev 的域名。 两个环境共享同一套用户名和密码,但是底层的数据要区分开。"
metalmaster-dev.ymp1.yuanmu-ai.com(分支 dev,工作树 MetalMaster-dev,数据根 /srv/metalmaster-dev,端口 8766)。nginx 配置 与生产同一套代理规则,限速区名/SSL 会话缓存名错开,另加 X-Robots-Tag: noindex(两个站内容高度相似,别互相抢排名)。证书按 「先装只监听 80 的 acme 配置、签完再启用 443」两步走,解开"证书与 nginx 互为前提"的环。MM_USERS_FILE 只把 users.json 一个文件指到 共享位置。只共享用户表——sessions/activity 每次请求都在写,而 _save 是整份重写不是追加,两个进程共用一个文件必然互相覆盖。sync_users():认证前 stat 版本戳 (mtime_ns + size),文件没动什么都不做。不能只在"查无此人"时同步—— 改密时人还在表里、只是摘要过期;写表前也先同步,别覆盖对方刚建的号。用户:"landing 页里面也增加一个 blog 和 changelog 的环节……都是静态的信息, 每次更新发布之前都应该把它们刷新一下"、"未来上线应该有一个 PR 的过程"、 "只有合到 main 上才能正常安装使用,其他的合到 dev 分支在测试环境做"。
/changelog 与 /blog(公开静态页,与 /welcome 同级,不碰用户数据): 更新日志直接读仓库 CHANGELOG.md 渲染——发布带着仓库走,页面天然是 最新的,不存在"忘了刷新"的第二份;博客读 docs/blog/*.md。落地页新增 「最近更新」「博客」两节各列最新三条。服务端 md 渲染器与助手气泡同一条 安全纪律(先整体转义、后替换),slug 走白名单。首发两篇:墨迹一本帐、 助手看得见零件也听得见意见。deploy/restart.sh 开头查分支,非 main 拒绝部署 (紧急逃生 MM_ALLOW_BRANCH=1);新增 deploy/restart-dev.sh 起测试环境 (/srv/metalmaster-dev :8766,与生产数据根/端口隔开);CI push 触发加 dev;docs/RELEASE.md 写清 PR 流程与发布前清单(含"刷新静态信息")。 规矩不靠自觉——tests/test_release_process.py 5 条钉死,变异 4 刀全红。pyproject 声明的 Python 下限扫,声明支持 3.10 就不许偷用 3.12 语法。用户:"助手只需要筛选一下是不是跟产品优化有关的问题,如果有的话就直接 存下来。未来你可以通过这些反馈来持续优化。"
save_feedback:模型在对话里听到产品问题或改进建议 (数据算错、功能不好用、希望增加什么)就提炼成一两句存进反馈本,并告诉 用户已记录——不用等用户点「反馈」按钮。筛选标准写在系统提示里:普通 提问、闲聊、对回答的追问不记。via: copilot,后台显示「·助手代录」——处理时知道这是 模型提炼过的转述,不是用户原文。这是工具箱里唯一的"写",写的只是 反馈登记,不碰分析数据、不触发计算。用户:"应该把 3D 的缩略图也作为助手的上下文,可以用来判断这是个什么东西。" 模型看一眼外形,比读一堆特征数字更快认出"这是个支架/法兰/机箱":
copilot.page_image:当前零件/整机的缩略图 → PNG,随最后一条用户消息喂给 多模态模型。与 page_context 同一条纪律——只读已缓存的那张画 (store.cached_thumb,旧版本算的也认:图画的是几何,文件没变图就对), 绝不为一句聊天触发投影。零件线稿栅格化 200 px(复用 Excel 导出那套 thumb_png,实测 ~1.6 KB / 约 50 token),整机位图原样;目录页没有 "当前这一件",不带图。img 字段:回放时知道模型当时看没看到图。用户:"你能不能整体调整一下,因为你画图的时候是能够知道之前的画到哪里了。" 对——此前避让是零散的:气球看 avoid、外移数字看 taken、视图名看 ink_bbox, 各记各的帐,帐本之间就是盲区。现在每个视图一本 Ink 帐(texts/segs), 画一段登一段,所有"要找地方放"的元素(气球、外移尺寸数字、剖切字母、 0,0 基准文字)对着全部已画墨迹计分选位:
_section_marks 只返回位置任务,字母等几何、尺寸、 气球全画完后从 6 候选里按墨迹代价选位(ets 阶梯剖 A 压线 865 段 → 0; 此前两次盲改回滚的正是它)。0,0 基准文字:只给视图内侧(向右)候选——向左伸会越出本视图足迹 压进邻居(ets 实测 21 段),而单视图的帐看不见邻居;代价 ≥ 压线 3 段 (与门禁同口径)就省略文字,符号本身已表达零点。门禁收紧:test_no_text_collisions_corpus 在"互压 == 0"之外加压线 ≥ 3 段 即红(全语料);test_balloon_tags_are_labels_not_sentences 钉死气球标签 是编号不是句子。变异 4 刀全红。
助手问答留痕("让这个智能体变成可用、好用、可追溯"的"可追溯"):每次 问答记 jsonl(时间/账号/页面/问题/回答/用了哪些工具/tokens/算法指纹),管理 后台新增"助手对话"节;token 用量只入日志不回前端。
用户:"出图的部分问题比较多,干涉得太明显。"真图(1950×50 长轨 @1:5)实测 9 对文字互压 + 气球压几何线 82 次。逐族修根:
svg_segments 漏解析 <path M…L…>(几何主体正是它,第一版对着 空场地打分)。ink_bbox 旋转文字按方向投影:竖排数字按水平记,外移长数字整段漏出 足迹(与"圆弧半径不是坐标"同族的墨迹反解盲区,单元门禁钉死)。first_angle_layout 的 below 行 pads 上下颠倒:进门扣了"下伸"、出门扣 "上伸"——俯视朝主视一侧的标注留地被少算 10 mm。以前上伸都小不显形, 数字学会外移后第一次撑到显形(busbar 实测主视底与俯视顶相犯 6.7 mm)。_fit 符号宽 0.75em(φ/±/× 曾按窄西文估,孔表溢列);0,0 基准文字 让位到符号左侧;数字底沿抬到 0.5H;审计对 rotate(0.0) 不再按旋转包络。新语料门禁 test_no_text_collisions_corpus:文字互压 == 0,全语料(判据 与气球避让同一双眼睛:sheet.svg_text_boxes/svg_segments)。压线检查本轮 不进门禁——剩余是贴线 1–3 段(制图允许)与剖切字母在阶梯剖落进几何带 (已回滚两次盲改,记为下一轮靶子)。变异 5 条 4 红 + 单元 1 红。
助手有工具了(用户:"要给助手更多的工具")。四件,全部只读/纯算—— 重算/导出这类动作刻意不给("替用户做主"的教训):part_brief(装配里任一件 的摘要)、feature_detail(超出截断清单的编号明细)、operations_estimate (确定性工时模型)、file_brief(目录里其他文件)。工具定义与执行只有一份, OpenAI(tool_calls)与 Anthropic(tool_use)两种线格式各自适配;单问最多 4 轮工具调用(防兜圈烧钱),单个结果截 3500 字。执行器与页面上下文同一条 纪律:只读缓存、绝不触发分析。
零件出图与主文件脱钩(用户:"/drawing?sub=0 为啥又驱动回了文件计算?")。 两处病:_maybe_offload 的 BREP 旁路明文排除了出图(kind != "drawing"), 零件出图按 48 MB 主文件的大小起整文件任务;drawing_html 的形状回退直接 load_step 整个文件。旁路对出图同样成立;回退先走 BREP 旁车(几毫秒), 拿不到才读整文件。门禁模拟"重启后 LRU 为空"的真实场景(不打空 LRU 时 get_part 顺手塞形状,回退路径永远没被踩到——变异抓出来的测试盲区)。
变异 7+2 条全红(其一以挂死形式被抓住;本轮教训:变异脚本的恢复必须放 finally——超时曾把变异态留在树上)。
助手回复此前按纯文本渲染,模型输出的粗体/列表/表格(系统提示明说允许表格) 以源码裸露。挂件内置极简渲染器 md():先整体转义再替换(注入的任何标签 只剩文字,有变异门禁钉住),只认约定的几样——粗体、行内代码、有序/无序列表、 表格(首行表头、分隔行跳过)、特征编号链接;块级样式全套跟着走(回复气泡 关掉 pre-wrap,块标签间的换行不放大成空行)。渲染结果用 node 执行同一段 代码验证过(列表/表格/XSS 三组样例)。门禁 +1,变异 3 条全红。
批量管理的枢纽页也有助手了。目录页挂件是目录形态:主动开口说 "N 件未解析、M 件需人工复核、K 件出自旧算法"(用页面已拿在手里的行现算, 零额外读盘),快捷问题换成批量口径(哪些没解析/哪些要复核/整体情况); 单件口径的三问不再混进目录页。对话上下文为整批文件的状态一览 (catalog_context,只读摘要缓存,绝不触发解析)。 门禁 2 条(目录形态/空 path 路由接线),变异 5 条全红。
对"交互是否足够简单"的复盘,按三条主旅程数点击,修掉两步废动作:
「补算未计算」按钮保留——它还是"手动马上补"的显式入口。 门禁 2 条,变异 4 条全红。
有了助手,交互的方向要反过来:不是人打开面板问"有什么要注意的",而是页面 已经知道的事由助手第一句话说出来。brief_of 在渲染时从报告确定性提取 (需人工复核 / N 条风险标记 / 工艺判定不明确 / 结果出自旧算法),欢迎语 直接列点、快捷问题换成针对性的("解释这 7 条风险标记""为什么工艺判定 不明确?");干净的页面保持通用欢迎语,不制造焦虑。零 API 成本。 件76 实测:本页有要注意的点:状态为「需人工复核」、7 条风险标记、工艺判定 不明确。门禁 2 条(主动开口/接线),变异 4 条全红。
门禁 +2(编号语言/快捷问题与转义),变异 6 条全红。
牌号不符的缓存不许端出去。 零件缓存槽不分牌号(path#index 一个槽, 换牌号重算会覆盖),指定牌号导出时盘上那份可能按别的牌号算——铝按钢差 2.9 倍,直接落在报价上。cached_part_entry/part_report_dict 的内存与磁盘命中 都加牌号校对(不符按未命中现算);不指定牌号则沿用盘上现有的,不逼人重算。 存储结构不动、缓存不翻倍。
配门禁时挖出两个潜伏崩溃——凡带牌号的零件分析路径从来就是坏的: dataclasses 三处使用从未 import(NameError);_remember 的键解析写在 "材料进键"之前,把 0|Q235 直接 int()(ValueError)。两处都修, 变异 5 条全红。
模型页面(文件页/零件页)右下角新增分析助手(server/copilot.py, 结构/样式/行为/后端一个包)。两条腿,一条不依赖另一条:
root/.metalmaster/feedback.jsonl);管理后台新增 「模型反馈」一节:待处理排前、每条带指回模型的链接与算法指纹(复现先看 指纹——反馈时的算法和现在的可能已不是同一版)、可标记处理/重开。 不需要配任何密钥,当下即全链路可用。quoting.condense_report(与 MCP quote_inputs 同产地)+ 工艺类型结论 + 标记)回答问题;绝不为一句聊天触发分析——没算过就如实说"还没算"。 模型调用只用标准库 urllib(零依赖口径),密钥从环境变量取 (METALMASTER_ANTHROPIC_KEY),未配置时如实说明并把人引向反馈。安全:请求体上限(对话 64 KB / 反馈 16 KB)、登录后可用、反馈进审计日志、 对话每用户每分钟 10 条节流(按 token 计费,一个账号不该有能力烧穿预算)。 门禁 6 条(链路/退化/上下文入 system/不现算/挂件自含/节流),变异 7 条全红。
件95(垫圈)曾被判"自由曲面件(铸造/模具)"——环面在面表里不记轴,永远进 不了回转分组,面积全算给"自由曲面"。而环面是解析回转面,主轴即回转轴:
全语料回归:13 件真实语料仅垫圈一件改判(freeform → turned),铜排/鱼尾板/ 件76 等全部不动;21 个合成夹具判型零变化。变异 3 条全红。
K 因子只进折弯让量(develop.py 里它的两处用途都在折弯条带/补丁上)——没有 一道弯的平板,K 取什么值展开结果按构造相同,而 _apply_developed 照样把 K 区间两端各算了一遍。注释里那句"多跑两次可以忽略"对 400 孔的冲孔板不成立: 一次 0.5 s,白算 1 s。现在无折弯(bend_strips 空且 n_op/n_geo/n_roll 全零) 时复用同一个实例——区间坍缩成一个点,正是平板的真实不确定度:零。体积恒等式 自检照跑。冲孔板整件 CPU 时间 4.76 → 3.86 s(−19%)。折弯件的 K 区间照扫 (门禁两个方向各一条,变异均红)。
shapes_in_list 里一句 list(NCollection_List)——OCP 的迭代器绑定每步跨一次 C++/Python 边界并做类型分发,实测 1212 个两元素列表要 1290 ms;改用 Size/First/Last 是 5 ms(258 倍)。它在建邻接表的循环里(每条边一次), 是整件分析的第一大热点:冲孔板占 24%。流形几何一条边恰好属两张面, 0/1/2 元素走快路径覆盖实际全部,≥3(非流形)退回慢迭代。逐边对照新旧取法 结果一致;性能预算门禁在机器被其他项目抢占的情况下也回到绿(此前被挤爆)。
黑色等待页整个退役(用户:"这个黑色页面应该去掉,然后融入到正常的页面中, 逐步披露计算出的结果")。三层配合:
htmlreport._CSS 主题 + asmview._RC_CSS 装配树),只补进度条几条自有样式——渐进页与最终页长得一致不是美观问题, "刷新一下换了个世界"会让人怀疑两边的数不是同一份。CSS 注释顺手剥掉 (快照每 5 秒整页重拉,注释纯是流量)。_stale_data 早就端旧页面了), 树照常显示。从点下重算到离线任务读完文件之间不再有空窗。整机网格走轻车道(用户:"整机三维网格……应该是优先级最高的,怎么会前面 还有任务在跑呢?")。准入规则收成纯函数 admit_job(唯一产地):网格任务 免名额、不免内存——名额是防"两个大件把内存吃光"的手段不是目的,网格是 正在看的那一页要的东西,不该排在批量补算后面;内存那条是硬的,读不到内存 信息也不开轻车道。维护任务不占名额(MAINT_TAG)是第一个先例,这是第二个。
交互细节:零件页「关键事实」置顶(数字先于图——点开一个零件最先要确认 "是不是这个件、多大、多厚");装配树根节点没名字时退到文件名(不再写 「(未命名)」);渐进页树根同样。
用户定的方向:"围绕一个 STEP 文件提取的关键信息……把这些功能都做成独立的 工具。"MCP 工具面补齐为 12 个:
bends:折弯明细(角度/内 R/折线长/方向)+ 三种计数口径(n_op 工序 / n_geo 几何 / n_roll 卷圆)。只要折弯就不必抱回整份报告。view_3d:自包含交互三维页(转动/缩放/按角色着色/特征编号标牌)。页面壳 复用 htmlreport._CSS 整份样式——只抄"用到的几条"就是共用结构漏样式的老病。drawing_sheet:工程图纸 HTML,浏览器另存即 1:1 矢量 PDF。标题栏取数与 "双渲染定比例"从 _Store.drawing_html 抽成 sheet.sheet_meta / sheet.render_with_scale——Web 页与 MCP 出的是同一张图纸,规则只有 一份(门禁:"org": "圆木智能" 这个标题栏哨兵在全仓只许出现在 sheet.py)。装配页的 BOM 表 / 装配树 / 重算按钮拆成 server/asmview.py(结构+样式+行为 一个包,app.py 3824 → 3349 行),规则零改动。
MCP 终于看得见装配体。 此前把装配体喂给 analyze/classify_part,走的是 "分析体积最大件"那条路——537 个零件的机架顶着最大那块板的标签回答"钣金件"。 现在:assembly_bom 给零件清单(只读结构,48 MB 整机秒级返回); classify_part 带 part_index 逐件判定;不带 part_index 问装配体直接报错 并指路,不再给一个看似确定的错答案。核心层同步补 classify.classify_assembly_part 与 classify_step 的装配体守卫。成本如实写进文档:read_assembly 的记忆化 只收 ≤8 MB(上限是刻意的——一条缓存持有全部零件实体,不设限进程会被几何 撑爆);大装配逐件判定每次都重读文件(48 MB 实测 ~58 s),批量拆大装配 请走 Web 层的 BREP 拆件旁车。
导出拆成 server/export.py。 build_export(store, form, uname) 是纯函数 ——不认识 HTTP,错误走带状态码的 ExportError,CLI/MCP 以后直接调它,不必 假扮请求。app.py 里的 _export 只剩收表单、落审计、发字节三件事(app.py 3926 → 3824 行)。规则一条没改,原有导出门禁全部原样通过。
「判断一个零部件的工艺类型」拆成可独立调用、可独立服务的能力。 新增 metalmaster/classify.py:一个 Verdict(标签 / 中文说法 / 判定依据 / 是否明确 / 竞争假设 / 正交的铜排标签 / 板厚 / 长宽高 / 标记码),三个入口 (verdict_of 从已算好的报告提炼、classify_shape、classify_step), 并作为 classification 块随报告序列化——页面、导出、MCP 读的是同一份。 MCP 新增 classify_part 工具(stdio 与 HTTP 两条路都挂)。
没有另写一份"轻量判据"。 只跑面表+事实能省掉一半时间,但最终标签取决于 "等厚性证伪之后的改判",那一步要先有折弯数、卷圆数、翻边孔。抄近路得到的是 一个在件76 那类件上仍然回答"钣金件"的第二产地——而件76 正是要修的那件。
明确的钣金件不再输出机加工口径(用户口径)。判不准的照旧输出:两套数 同时在手上,是工艺拿不准时唯一的退路。分界线用 Verdict.confident 一个定义 (改判过 / 记了竞争假设 / 挂着质疑板厚的标记 → 不明确)。顺带把图纸标题栏的 重量从 machining_inputs 改回 dims.mass_g——质量本就只该有一个产地。
三视图:圆要画满一圈。 _edges_to_polylines 把圆弧的起终角直接取了 t0/t1, 而那是圆自身坐标系里的参数角。HLR 把整圆切成两半后,两半在各自坐标系里 都是 0→180,于是上半圈画两遍、下半圈整个丢掉——件95(垫圈 OD17/ID13)正面 那两个同心圆在图上只剩两道弧。改为按实际起点在视图里的方位定起角、按圆的法向 定扫掠方向,并剔除长度可忽略的碎弧(件95 上有一条 0.02° 的接缝边,记了它反而 让对应的边一条都不画)。
三视图共用一把比例尺(projection.common_scale):原先每张各自撑满画布, 一块 100×17×2 的条在俯视与侧视上比例差近 6 倍。
导出:旧算法算过的结果直接导出、不重算(要不要更新是用户的决定),混着 两代算法的数则多一列「结果版本」逐行注明;缩略图内嵌进 Excel(xlsx 写出器 支持 drawing/media,线稿用 Bresenham 扫成 PNG,不重新投影)。
冗余清理:删掉两个全仓无调用者的函数(tessellate.tessellate_solids ——整机三维早已改走"每种一份网格 + 实例位姿";fixtures.gen.roundtrip); 展示串的拼装规则抽成 table.kind_display_of,dict 适配器与 Report 适配器共用 一份,铜排「铜排(钣金件)」与装配体短路不会再各写一遍。
算法侧本波不加特性,只做两件事:把判据的结构立起来,以及修判据本身的错。 完整记录见 docs/ALGORITHM.md 的「一¾、判据的结构与这一波的算法改动」。
判据阈值收成唯一产地。「这两个方向算不算同向」原有 8 把尺子(本值、×2、×4、 裸 2/3/5/10°、dot>0.999、||a·b|−1|>0.02),收成四级命名阶梯,各有物理定义, 选级别而不许乘倍率;「两个折弯角算不算同一个」按四个不同的问题分别命名;以 t 为单位的长度判据全部命名(0.95t 曾抄三遍)。守门禁止所有 *_deg 乘倍率、 parallel() 传裸数字、手搓共轴判定、核心路径的裸角度与裸 ×t 系数;绝对长度一律 带 _mm 后缀(补掉两个真实的缩放不变性洞)。
修掉的判据错:
skin_width_over_t 1.5 → 1.07。原值落在皮肤那一族内部(照薄板挑 的),厚板的皮被自己的判据挡在门外;1.07 ≈ √(0.99×1.15) 是两族边界的几何中点。T_BY_SKIN_GAP);皮肤法改为候选自洽,去掉会退化成 绝对 1.5 mm 的 t_hint。_classify_part 有 6 个出口而证据侧只有 4 条, 选中缺席标签直接抛 ValueError,整件一个数都出不来。立 PART_KINDS 唯一词表 + 静态扫描钉死两边。展开几何口径修正:
line_len(外侧跨度)在两弯相交的角上高估了折弯 区的料(内面渐缩),adx139058 的体积恒等式 1.000152 全部来自此。改用 L_eff = 内面面积/(θ·Ri) 后为 0.999999,developed_area_ratio 1.0002 → 1.0。 只改材料账,条带仍按 line_len 画(渐缩条带留作后续)。notch_ears 56.28 报成 81.28,偏大 44%)。折弯让量的轴向区间先过 oriented_interval 归一。验证体系(docs/ALGORITHM.md D 节): 锚点按 external / kernel / derived / frozen 分级并写进 expected.json;毛坯长度有一维 vs 二维双路径(12 件 0.00%)与 11 个夹具的手推真值;切割周长的独立裁判从 5 件 ≤2% 扩到 15 件 ≤0.5%,另加凸包 周长在 12 件上取等号;折开缝首次有独立实现互验;面板落位有等距/无缝/无叠三条直接 判据;体积恒等式从 2% 收到 1e-4(19/24 件)。每个豁免必须声明成因并断言该成因 可观测。
其它: build_developed 524 → 326 行(抽 5 个纯函数,构造级论证 + 跨树 27 件 3307 坐标点逐位一致);删 6 个无人调用的函数(含一个会引人走回头路的废弃判据)。
upload_step(filename, content_base64),解码后最大 10 MiB,只保存 STEP/STP 并返回绝对路径,超限返回 FILE_TOO_LARGE;POST /upload,流式上限仍为 200 MiB;/upload 路由冲突;/mcp)与普通 /upload 新增两个独立、默认关闭的鉴权 开关;命令行可覆盖环境变量,两个入口共用一个 Bearer 机器密码,Streamable HTTP 启动时若开关与密码配置矛盾则拒绝启动;127.0.0.1、localhost、::1 或明确的 RFC 1918 IPv4, 拒绝 0.0.0.0、::、公网/特殊地址、其他主机名和其他 IPv6 地址;html_report、unfold_dxf schema 不再提供 out_path,服务固定采用 STEP 旁的输出路径;docs/http-integration.md,说明第三方架构、工具、鉴权、局域网监听、 MCP/HTTP 输入输出和平台中立部署方式;展开图里的轮廓是采样折线(一条直边被切成十几段),直接标注没法看。新增 develop.cut_lines():按"转角小于 3° 即同一条线"把连续段并回逻辑上的一条, 每条给出长度与类型(外轮廓 / 内部开口)。
这份明细存在的意义是可核对——报价员能把每段长度加起来,对上报告顶上那个数。
第一版明细自己抄了一遍轮廓切分逻辑,结果外轮廓段合计 11780.7 而 contour_mm 只有 2681.0——差 4.4 倍。成因是拆了每块面板的整圈, 把面板之间的拼缝也当成了切割线,而拼缝一刀都不用切。
修法不是"再对齐一次",而是只留一套实现:
_free_segments() 成为"哪些边要真的切一刀"的唯一定义;_free_perimeter() 退化成它的求和;cut_lines() 也从它出发。于是"明细加起来 ≠ 周长"在构造上不可能发生。两件实测差 ±0.02 mm。 这个项目最贵的几个 bug 都是"同一件事两套实现、各说各话",这次从源头消掉一处。
facts._evidence_of / _strongest_rival——早先"证据 argmax 否决"撤回后的遗留,无人引用;develop._perimeter——_free_perimeter 已是唯一口径后无人调用。共减 46 行。另确认:算法代码里零件名只出现在注释中,没有任何一处按文件名分支。
| | 标准答案 | 客户口径 | 差 | |---|---|---|---| | adx139058-00_5 | 2699.0 | 2698.6 | −0.4(−0.01%) | | ADX131200MJYF-00 | 4341.7 | 4408.6 | +66.9(+1.54%) |
口径构成透明:简化毛坯轮廓 + 非圆开口。自洽性:切割线明细 = 轮廓周长(±0.02), 体积恒等式 1.0002 / 1.0000。
ADX131200 剩的 +1.54% 不去凑。要定案需要客户那边的毛坯尺寸 (我们算 912.65 × 709.65,中性层法与外形法交叉验证一致: 638 + 2×(33+2.827) = 644 + 2×36 − 2×3.173 = 709.65)。
全套 940 项。
用户要求:先确保切割总周长合理;若仍对不上标准答案,可另开一个 2 号口径, 但 2 号也不能过拟合;若 2 号本身就更合理,就不必另开。
试出来的新口径两条都占,所以直接替换,没有另开:
| | 改前(真实轮廓+非圆开口) | 改后(简化毛坯轮廓+非圆开口) | |---|---|---| | adx139058(标准答案 2699.0) | −0.67% | −0.01% | | ADX131200(标准答案 4341.7) | +2.83% | +1.54% |
把每块面板/条带换成它的外接矩形,再求并集外轮廓。报价常用的"理想化毛坯" 口径:不数圆角的缩短,也不数凸耳与工艺缺口这些型面细节,只认骨架形状。 外接矩形是确定的,不需要"多小算小"的判断。
实测 22 件:简化/真实 轮廓比中位 1.000(p10 0.996、p90 1.010)——多数件 根本不变,只在有凸耳/缺口的地方才分开,正是意图所在。
ADX131200 仍差 +66.9 mm(+1.5%),归不出具体几何项。已排除凸耳/缺口 (简化轮廓已扣掉那 123)。不为消掉它加特判——那正是本项目反复栽过的坑。 要定案需要客户那边一个信息:该件的毛坯尺寸(我们算的是 912.65 × 709.65)。
先前说"客户反推的外轮廓 3177.7 比毛坯外接矩形还小,几何上不可能"——那个 3177.7 是在「客户口径=外轮廓+方孔」这个假设下反推的中间量,不是标准答案。 假设若不成立,结论也不成立。现用口径不依赖该假设。
全套 940 项。变异验证:把简化轮廓退回真实轮廓 → 2 条红。
对两个客户标准答案逐条比过所有候选口径,只有一条两件都落在 3% 内:
| 候选口径 | adx139058 | ADX131200 | 最差 | |---|---|---|---| | 外轮廓 + 非圆开口(不含圆孔) | −0.7% | +2.8% | 2.8% | | 外轮廓 + 全部孔(total_cut_mm) | +11.4% | +8.0% | 11.4% | | 材料轮廓 + 全部孔 | +13.8% | +8.0% | 13.8% | | 外轮廓(不含任何孔) | −0.7% | −24.0% | 24.0% | | 三维壁面 (SIDE+HOLE)/t | +14.2% | +7.9% | 14.2% | | 毛坯外接矩形周长 | −1.9% | −25.3% | 25.3% |
它有物理含义、不是尺寸阈值:圆孔冲/钻,不走切割路径;异形开口(腰形槽/ 矩形/异形)必须走轮廓刀路。这是车间按成形方式的区分,对两件是同一条规则, 没有任何逐件特判。
残差成因都已查明:adx139058 的 −18 是 R5 圆角(客户按尖角记账); ADX131200 的 +123 是 8 个建模出来的折弯止裂口(客户用简化轮廓)。
明写的不确定性:现有两件都没有大圆孔,所以本规则与「大圆孔也走轮廓刀路」 在样本上无法区分。新增测试 test_customer_caliber_beats_alternatives 比的是 各口径的相对优劣——将来若有人为凑某一件去改定义,只要另一件变差就当场变红, 比放宽容差诚实。两条变异(改成含全部孔 / 只算外轮廓)分别红 3 条和 2 条。
这些数背后都有前提,最要紧的一条是「圆孔按冲/钻计,不入刀路」。读的人当场看不到, 就会拿一个口径的数去对另一个口径的答案(本项目为此来回查了好几轮)。
表头改为:短说明直接印在列名下(≤18 字),长的挂一个问号、悬停或键盘聚焦 展开浮窗。用 <button> 而非原生 title——原生的要等一秒、没有样式、也看不出 可交互;<button> 还能 Tab 聚焦(:focus-visible 同样展开)。
total_cut_mm 也补上了口径说明,并指路「若你的报价按圆孔冲/钻计,请看轮廓刀路 总长那一列」。
全套 940 项,0 失败(上一轮那次 perf_plate 超支未再出现,进一步印证是环境负载)。
按"重点盯切割总周长"做了一次四路交叉审计(展开刀路 / 展开材料 / 三维壁面面积÷t / 内环拓扑),查出圆柱路径仍在造假特征——占孔切割长的 15.3%,多件 100%。
实测把两个总体分得一干二净:
| 来源 | n | 深度/板厚 最小 | 中位 | <0.5t 的 | |---|---|---|---|---| | 有内环佐证的真切割 | 231 | 1.000 | 1.000 | 0 个(0%) | | 圆柱独有 | 41 | 0.015 | 0.172 | 29 个(71%) |
231 个真切割特征,深度/板厚的最小值恰好 1.000,无一例外——切割必须穿透材料, 这是物理必然,不是统计规律。而幻影特征最深的不到半个板厚(最小 0.015, 即 1.5% 的板厚)。中间空着整整一倍,取 0.5t 落在空当正中,对真切割留 2 倍裕量。
典型形态:15 mm 板上报出"深 0.5 的腰形槽"——那是棱边倒圆被凑成的。
这条与 v0.24.0 的轴向判据互不替代:那些槽的轴不平行于墙法向,轴向判据抓不到, 却占了那些件孔切割长的 100%。
抽样 58 件:9 件变化(全在同一批客户来料),孔切割总量 −10.1%, 没有任何一件的切割长变大(正确——只该去掉假特征),无状态变化。
审计中标记的 SF26130-C201-D8 从 545.0 落到 430.4,正好等于内环法(拓扑枚举) 的实测值——两条独立的路从此对上。
test_perf_plate_patterns_and_budget 在本机当前状态下超预算(16.8s vs 15.0)。 改动前后一模一样,且一小时前 v0.26.0 跑完整套时它是过的;机器 load 2.75/5 核, 整套耗时从 9 分钟涨到 18 分钟。判定为环境性能漂移,不调这个预算—— 调松它等于把一个真实信号关掉。
全套 936 通过 / 1 环境性失败。
上一版查毛坯利用率时留下的头号问题:一批 15 mm 板报出的毛坯只覆盖了最大那张 面,另有 20% 的料在它之外。这一版把它证明出来并说清楚。
平板的材料体积恒等于「轮廓面积 × 板厚」。V/t 超过最大的那张平面,就说明料跑到了 这块平板之外——它不是等厚平板,而是带台阶/凸台/筋的机加件。
无参数,且不依赖展开算得对不对(比展开自检更早一层)。倒角只会让 V 变小 (V/t < A_max),所以越界只有一个方向的成因,不存在"正常件偶然越界"。
29 件 0 折弯的真实来料实测:正常的恰好 1.000,越界的从 1.050 起, 中间是空的。命中 16 件,全部集中在同一批客户来料,其他批次与全部夹具零误报。
先前说"展开丢面板"——错了。实测 展开自检 = A_max/(V/t) 逐件精确成立 (7 件里 6 件差 0.00–0.01%):展开正确摊出了那张面,错的是 V/t,因为 t 不是 这些件的等厚厚度。两个信号是同一个测量的两面。已写进测试钉死。
第一版漏了两种"把料移出平面"的成形,当场在自己不适用的地方开火:
roll_section 比值 5.02):料是卷起来的;AGS000190 翻边板):领口的料是从平面里拉出来的。两件的置信度都被误扣到 0.52。补上前提(n_roll、翻边孔、DRAWN_RING、 预冲孔)后恢复。这不是"例外清单",是把前提说全——判据的适用范围本来就该 写在判据里。
SF26130-C201-D8 的 展开自检 与 1/比值 差 3.5%(其余 6 件差 0.00–0.01%), 成因是孔在两条路上记账不同(面面积已扣孔,展开净面积又扣了一次)。 记在测试注释里,另行处理。
全套 928 项。
全量体检(128 件缓存)发现一个定义级的错误:毛坯利用率 = 展开净面积 / 毛坯 外接框面积,净面积是外接框的子集,所以恒 ≤ 1。而 16 件报出了 > 1, 最严重的 SF26130-C3B09-D3 报 3.543。
根因链一查到底:
板厚 6.531 ← T_BELOW_GEOMETRIC_FLOOR 已判定「低于几何下界 7.22」
↓ V/t 虚高
展开只摊出 4 张墙面里的 1399/2708 mm²(体积恒等式 0.2822)
↓ 毛坯框随之缩小
利用率 4956 / 1728 = 3.543三个独立信号指向同一处。
处置是不报,不是夹到 1——夹到 1 等于把"展开失败"伪装成"利用率 100%": 报价员看到"不适用"会去查,看到 1.000 不会。与本项目一贯口径一致 (宁可承认量不出来,不可拿默认值冒充测量值)。新增 BLANK_UTILIZATION_IMPOSSIBLE(warn),并置利用率为不适用。
顺带试了把板厚可行性余量从 1.2 收到 1.03(贴近恒等式硬界)。聚合指标看着漂亮: 体积恒等式平均偏离 0.0514→0.0410、利用率最大超出 2.543→0.252。
但那是差生离场,不是成绩提高:7 件改了板厚的件全部退出钣金主线;在两版 都仍是钣金件的 64 件上,指标一字不差(中位 0.0002、平均 0.0410、>5% 13 件、 利用率超 1 的 16 件、最大 0.252)。已回退,不采用。
变异测试两条:关掉检测 → 2 红;改成夹到 1 → "报警而非静默夹紧"那条红 (它专门区分"置空"与"夹紧")。
全套 916 项。
前两次修假缝(折弯条带只按圆弧宽度铺,两侧留出深度恰为一个折弯让量的窄缝, 被 _free_perimeter 当成外轮廓)都栽在同一处:加宽写进了材料口径, 体积恒等式当场崩(notch_ears 1.1621)。这次先查了行业惯例:止裂口(bend relief) 显式建模才是切口,否则毛坯轮廓就是干净的台阶——客户答案正是按这个口径给的。
net_area_mm2(材料):原始条带,一毫米假料不许添,体积恒等式把守;contour_mm(刀路):_strips_for_contour 加宽后的条带,客户报价口径;metrics.material_contour_mm(材料周长):新增暴露,交叉校验(厚度面面积÷t、 独立采样并集)从此对它,另加单边不等式:刀路 ≤ 材料。两本账各有各的裁判——恒等式在构造上不可能再被这条改动破坏。
从折弯展开出来的材料——无论经圆弧相连还是法兰超出圆弧宽度的自由边——根部边都 精确落在"折弯线+让量"这条线上。所以取这条线上的面板边段清单为准:
全套 906 项。
0.24.0 的规则「圆柱特征若轴平行于平面墙法向、却没有对应内环,即剔除」漏了一种 情形:骑在折弯线上的孔。它的轴确实垂直于平面墙,但它把平面墙的内环撑破 (并进外环),内环法因此看不全它——那正是圆柱路径该管的。自检 hole_straddle 的孔数因此 1→0。收紧为:同时不与任何折弯面相邻才剔除。自检 16 件全部通过。
成因早已定位:折弯条带只按圆弧面宽度铺,两侧留下深度恰为一个折弯让量的空带, _free_perimeter 把空带的边当成外轮廓。两件客户答案都指向它 (adx139058 多 46.6、ADX131200 多 123.0)。
这次换了判据——不再无差别按并集铺,而是要求空带上下两侧都有料才补。 adx139058 从 +47.0 收到 +14.7,看着有效。但体积恒等式再次崩了: notch_ears 1.0000 → 1.1621(与上一版并集方案分毫不差的同一个数)、 corner_bracket → 1.0898、U_slotted → 1.0716、welded_bracket → 1.0792。
同一个夹具、同一个数值——说明"两侧都有料"这个判据在这些件上与"并集"等价, 根本没有分辨出真缺口。第二次尝试,同样回退。
体积恒等式(展开净面积 ≡ V/t,折弯不增减材料)两次都当场抓住了它。这条判据 无参数、不可调,是这个项目里最可靠的一道闸。
用户报 adx139058-00_5 的切割总周长应为 2699,我们给 3072.3。
查下来问题不在周长算法。新增一条只用体积、表面积、板厚的恒等式做交叉验算:
钣金件的表面积只有两种来源——两张皮(各 V/t)与每道切口留下的侧壁(长 × t)。 于是 A = 2·(V/t) + 切割总长 · t,即 切割总长 = (A − 2V/t) / t。它不依赖展开算得对不对,也不依赖面的角色分类。对这一件:
| | 值 | |---|---| | 恒等式(只用 A、V、t) | 3100.3 | | 我们的展开口径 | 3072.3(差 −0.9%) | | 用户的标准答案 | 2699(差 −12.9%) |
孔那部分另有独立佐证:孔壁面积 816.8 ÷ 2.5 = 326.7,与我们报的孔切割完全一致。
所以这个模型里确实存在约 3100 mm 的切口侧壁。 差的那 400 mm 不是算错, 是"哪些边算切割"的口径分歧——见下。
折弯线和它本该连接的底板不在同一段区间上——这不是能折出来的法兰。 展开树因此把端板摆错了位置(还镜像了:三维 x 是 −66…15,展开图放成 −20…61), 毛坯宽度从 ~81 涨到 97.14。这件本身带着 UNFOLD_MULTI_AXIS 警告。
若把两块端板按独立下料件排除,主体毛坯的切割长是 2614.9——2699 落在它与 3072 之间。需要你确认:2699 是否只算主体、不含两块端板? 这一条我不能替你定。
同一条校验在 61 件上跑了一遍:中位偏差 1.0%(说明它确实能当校验用), 但有 19 件超过 25% 的门槛,其中最严重的一件(AGS000020-19)切割长是恒等式的 9.2 倍,此前状态是 ok、一声不吭。新增 CUT_LENGTH_INCONSISTENT(warn)。
门槛取 25% 而不是 5%:恒等式的误差源是二阶的(折弯处内外弧面积差、倒角), 实测中位 1.0%、夹具最大 6%——5% 那条线会随语料漂移,"量级对不上"不会。
全套 901 项。
用户反馈"这个图不行,视角还有字号都有比较大的问题"。两处都确认是硬伤, 而且都能量化:
ets01835 的包围盒是 359×31×500,最薄的是 y 方向。而我直接拿世界坐标系的 Z 当主视方向——正对的是 359×31 的厚度边,等于把一块大板立起来看侧棱,整张图 是废的。
改成按各轴的实际尺度排序,最薄的那条当主视方向:主视图永远正对最大的面。 这不是习惯问题——正对最大面,轮廓与绝大多数特征才同时可见且投影成真形。
每个视图的 viewBox 贴着自己的内容走,整个 SVG 又被 CSS 缩到格子宽度——两套缩放 叠在一起,而字号只声明在第一套里。实测七个视图的字号跨度 3.4 倍,多数小到 看不清。
改成所有视图共用一块固定画布(560×400):1 个 viewBox 单位 = 1 个屏幕像素, 字号所见即所得;几何仍按公共比例缩放并居中,跨视图能对量。同时把网格列宽从 280px 提到 420px,贴近画布宽度,避免 SVG 被二次缩小。
dims.frame 有值,而 frame 的第一 条轴本来就是板厚方向。排序逻辑只在 frame 为空时才起作用(真实来料 ets01835 正是如此)。最后改成直接测 _axes + 真实件兜底,变异才见红。112 件复核:0 处尺寸落空、0 件异常、0 处几何越出画布;视图数分布不变。
每份 STEP 多一个入口"工程图":自动选投影面、标注尺寸与公差、内部结构自动加剖面。
"投影面尽可能少,但所有尺寸都标得出来"有个精确的说法——集合覆盖。 每个尺寸只在特定方向上量得出真值,于是变成"用最少的视图覆盖全部尺寸"。 候选只有三条主轴,枚举 2³ 取最小即可:精确解,无启发式,无可调参数。
三条判据都是制图的定义,不是标定出来的:
| 尺寸 | 什么时候量得到 | |---|---| | 线性(沿 d) | d 躺在投影面内才是真长 → \|d·n\| ≈ 0 | | 直径(轴 a) | 顺着轴看圆才是圆 → \|a·n\| ≈ 1 | | 深度 / 折弯角 | 要横着看才量得到 → \|a·n\| ≈ 0 |
112 件实测:0 处尺寸落空,0 件异常;视图数中位是 2(68 件只需两个视图, 26 件三个,仅 6 件需要七个)。
盲孔深度、沉孔台阶、内腔——它们在任何外部视图里都只是虚线,而虚线不是尺寸 界线。这类尺寸单独归组走剖视。同一视向的合成一个阶梯剖(刀折几次穿过所有 特征),不是每处一刀。特征轴不平行于任何主轴时补斜视图——那是制图本来就有 的做法,不是变通。
一处几何关系我先搞反了:全剖视图的剖切面法向就是视图方向(顺着箭头看剖切 面),我却取了另一条轴,结果一个剖面只剩 44 段线、另一个整个切没了(0 段)。
112 件真实来料里带公差实体的是 0 件——连其中 14 个 AP242 也没有。所以任何 "从这个 STEP 标出公差"的说法都是编的。但图纸上"没标公差的尺寸"本就有确切含义: 按未注公差执行。于是:文件里带 PMI 就读它、注明来源是文件;读不到就按 ISO 2768 的尺寸段给,并在图纸上写明这是假设的标准等级、请按实际图纸核对。 两者在尺寸表里分列"公差来源",不混为一谈。等级可用 ?tol=f|m|c|v 切换。
同规格的孔并成一条(9×φ7,不是九条 φ7),ets01835 从 71 条标注降到 35 条; 所有视图统一比例(比例不一致就没法跨视图对量)。图上的特征号与分析报告、 三维视图里的 B/H/C 完全一致——你说"H7",三处指的是同一个孔。
全套 882 项。
用户要求把整个算法再过一遍,找更稳、不会过拟合的判据。结论是:判据稳不稳, 取决于它的形式,不取决于把值调得多准。按形式分只有四类:
| 形式 | 例子 | 稳不稳 | |---|---|---| | 无量纲比值 | roll_r_over_t、developed_area_tol | 缩放不变,稳 | | 角度 | theta_parallel_deg | 缩放不变,稳 | | 绝对长度 = 模型精度 | coax_dist_tol_mm=0.05 | 是文件格式的属性,不是零件的,稳 | | 绝对长度 = 其他 | t_max_mm=12、pattern_line_tol_mm=1.0 | 危险:零件放大缩小结论就变 |
把 16 个夹具在 0.25×–4×(16 倍跨度)上全跑一遍,只查无量纲结论。 结果比预期好:只有一处变化——z_offset 放大 4 倍后少一条风险标注, 根源是 t_max_mm=12 这条真正的物理先验(钣金就是薄的),属正当后果。 状态、形态、折弯道数、孔数在 16 倍跨度内一字不变,已固化为门禁测试。
t_max_plate_mm、 implausible_size_mm 等 7 个字段全都不在里面——等于新阈值可以悄悄绕过 门禁。改成从 AnalyzerConfig 按 _mm 后缀自动派生,另加一条命名约定 测试兜底,清单再也漏不掉。machining.py 里 8 个阈值散在 config 之外,违反 config.py 第一行自己写的 规矩。全部搬回 config,其中四条明确标注为"照 108 件标定的经验值,换批需 重标",且一律 info、扣 0 分。t ≥ 2V/A 是恒等式——两张皮的面积合计不可能超过零件总表面积。111 件里 4 件的最终板厚违反了它,其中一件走的是钣金主线(t=6.53,下界 7.22), 它的展开、毛坯、估重全建立在一个几何上证明为错的板厚上。
挑候选时用的 20% 余量(_feasible 里的 1.2)是照当时语料定的,注释写着 "1.2 落在空隙中间"——111 件重测下来那个空隙已经没有了。但我没有把 1.2 改成 别的数:那只是照新语料再标定一次,同一个错误。试着收到 1.02,7 件板厚跟着变, 而哪个对我无从验证。
所以选择逻辑一个字不动,定稿后按恒等式验算,违反就报 T_BELOW_GEOMETRIC_FLOOR 并给出蕴含的下界("板厚至少 7.22 mm"——可复核的事实)。112 件实测: 板厚变动 0 件,新增 4 条如实报告。
sheetness ≥ 0.5 的判据线是推导出来的(等价于"面内尺寸 ≥ 2 倍板厚"), 这一点没问题。但注释写着"46 件真实钣金 0.667–1.00、机加件 0.31,分离很干净" ——111 件重测:钣金件低至 0.507、非钣金高至 1.186,两个总体大面积重叠, 一半以上的非钣金件在线的上面。真正分开它们的是 has_skin_pair(板厚必须是 两张皮之间的距离)。已按实测改写,并确认贴线的件会照实进决策记录并转人工。
全套 867 项。
0.21.1 把"在不在规格表上"整条拿掉,连一个真信号一起扔了——CI 上一条既有测试 当场变红:t=7.62 mm 的板(= 0.3 英寸整)不再被怀疑单位。它本该被怀疑。
漏掉的那个判据是"分毫不差":真的单位误标换算出来落在整数档上, 7.62/25.4 = 0.30000;而 15 mm 板换算是 15/25.4 = 0.5906,离 0.6 差 1.57%。 按规格表那个 3% 的宽容差,两者都算"命中"——这才是 17 件误报的直接成因。 新增一条严容差(0.5%)专用于换算值:分毫不差才配叫精确命中。
于是三条判据各司其职,谁也不越位:
| 情形 | 结论 | |---|---| | 读数在现实里讲不通(板厚越界 / 外形 > 3 m),换算后讲得通 | 强怀疑,转人工 | | 读数讲得通,但换算后分毫不差落在标准板厚上 | 值得看一眼,转人工 | | 读数讲得通,换算后只是"差不多"(15 mm 板这一类) | 不怀疑,仅 info |
这一版还纠正了我自己写错的一条测试:原来断言"15.24 mm 也该放过"——错的, 15.24 恰好等于 0.6 英寸,分毫不差,本来就该看一眼。把自己编的断言当成事实, 是另一种过拟合。
112 件实测与前两版相同(消 17 条误报、置信度上升、下降 0 件);全套 822 项。 教训另记一条:这轮为了省时间只跑了改动相关的测试,于是漏掉 test_process_notes.py 里那条——"跑少一点"要按影响面挑,不是按记忆挑。
用户一句"我怕过拟合",回头审自己昨天写的东西,最可疑的一处确实是过拟合。
0.21.0 消掉那 17 件误报的办法,是把 15 mm 等中厚板规格补进 common_thickness_mm。 那正是"看见 17 件 15 mm 板,就往表里加 15"的形状——下一批来个表上没有的厚度, 同样的误报还会再来一次。
根子在于触发条件用错了先验。 25.4 倍的单位错误在形状上是测不出来的(缩放 不变:比例、薄板性、折弯角全都不变),只能靠绝对先验拆穿。而绝对先验有两种:
| | 稳健性 | |---|---| | 一张清单(常用板厚规格表) | 天生不完备,缺一档就是一次误报 | | 几条范围(板厚 0.3–25.4 mm、单件外形 < 3 m) | 不完备性小得多,不随来料批次变 |
触发条件改成按声明读出来的板厚与外形在现实里讲不讲得通、换算之后讲不讲得通, 不再是"在不在表上"。规格表恢复原样(仍停在 12.0),退回它本来的位置: 一条 info 提示。
验证方式是把表清空到只剩一个值,那 17 件仍不误报——证明修复不依赖表里的 任何一个数。112 件实测结果与 0.21.0 完全相同(消掉 17 条误报、置信度上升、 下降 0 件),但不再依赖我补进去的那些值。
顺带:厚度超出表的覆盖范围时,措辞从"不合常用规格"改成"超出规格表覆盖范围, 未作规格核对"——中厚板常备哪几档取决于客户的供应链,不该由这张表替他断言。
量了 270° 那条扫掠角判据在真实数据上的余量:判为"非孔"的段最大 268.5°, 判为"孔"的段最小 273.2°——只剩 4.7° 的空当。这么窄说明阈值本身不稳。
做不到稳,就退而求其次把影响范围钉死:钣金件的孔数与切割周长走内环法 (拓扑枚举,与扫掠角无关)。新增测试把阈值从 200 拉到 340,真实件上结果必须 一字不变。靠近边界的段几乎都是凸面(外圆边,本就不计孔数),唯一靠边界的 凹面是一块钣金件上的 φ20.5 沉孔——内环法照样找得到,不依赖这条线。
从"哪些件还在转人工、为什么"倒推着查,两条都是系统自己跟自己打架—— 同一份报告里两个数对不上,比只给一个错数更难查,因为读的人不知道该信哪个。
板厚规格表停在 12.0 mm——正是旧的 t_max_mm。0.17.0 把板厚判定放宽到 25.4 mm 收下中厚板下料件,却没同步这张表,于是每一件 15 mm 板都"不合常用规格"; 再被 ÷25.4 的分支捡走:15/25.4 = 0.5906,落在 0.6 mm 规格 ±0.03 的容差里, 于是报"换算后 0.59 精确命中——单位声明存疑"。17 件真实来料因此全部转人工。
补上中厚板规格(GB/T 709 常备系列 14/15/16/18/20/22/25/25.4),并写成断言: 规格表必须覆盖到与 t_max_plate_mm 同一个范围。
补表之后反向能力掉了,测试当场抓住:0.6 mm 薄板按英寸误标读成 15.24 mm, 与真 15 mm 板只差 1.6%,而 15 mm 热轧板轧制公差本身就有 ±0.5 mm——光看板厚 这一个数,两者分不开。所以换第二个信号:单位误标会把每一个尺寸放大 25.4 倍,一件 500 mm 的板读成 12.7 m。外形不离谱时就没有理由怀疑单位。
板厚自检改选(T_RESELECTED)取的是"两张大皮之间的实测距离",它未必在投票 候选里;而决策记录照旧按"离最终值最近的候选"填 chosen,于是报出一个既不是 最终值、概率还是 0 的选项——采用一个概率为零的项,这话本身就不成立。
改选发生时改记真正的岔路口:*按两张大皮实测* vs *按投票簇*,并写明后者是被 皮肤配对判据证伪的。另立一条通用不变式做测试:决策记录里"采用"的那个值, 必须就是报告给出的那个值,且概率不为零。
112 件实测:18 件变化——17 件消掉 UNITS_GAUGE_MISMATCH、置信度上升 (0.30→0.55、0.45→0.70 这一档),1 件补上一条本就该有的决策记录; 置信度下降 0 件,新增误报 0 件。
0.20.0 的特征编号只覆盖钣金件——非钣金件走的是提前返回路径,压根不跑孔识别, 于是铣削件在三维视图上一个号都没有。而用户这批件"大概率机加工"。
钣金那条孔识别在这条路上确实跑不了(它以"穿透板厚"为前提),但机加工口径 已经把孔逐个找出来了,直接拿来编号即可。为此让 machining._holes 除了按规格 归并的汇总,另出一份逐个孔的清单(带位置)——汇总那份报价够用,要在图上 给每个孔挂号就得知道每个孔各自在哪。
编号规则与钣金件同一套,两条路上的 H3 是同一种东西——用户不必先想"这件是 钣金还是铣削"再决定号怎么读。铣削件不判通/盲(没有"板厚"可比),也不给切割 周长(那是落料口径)。
111 件逐件对比:状态/形态/板厚/折弯/展开/孔数/置信度一项没动,纯属新增。
deploy/restart.sh 之外,操作记一条教训:pkill -f "<模式>" 会匹配到发起它的 那个 shell 自己(命令行里就有那串字)。今天两次因此把自己打断——一次让站点 502。凡按模式杀进程,一律先 pgrep 拿 PID 再 kill。
补完编号后顺手对了一下两条口径,发现同一件上同时成立两句话:真实来料 SF26130-C3B05-D1 的通用几何口径说"孔总数 5",机加工口径说"0 个孔"。 翻出来看,那 5 个是 27°–44° 的棱边倒圆——凹圆柱不假,孔却谈不上。
这是今早在 machining.py 里修过的同一个毛病(圆角当孔),只是留在了另一个模块: facts.refresh_cylinders 把每一张非折弯圆柱面都计入,凹的叫"孔"、凸的叫 "凸台",不看扫掠角。现在两处共用同一个函数 machining.wrapping_fids(), 判据只有一条:包不包住轴。
判据本身踩了两个坑,都是量真实数据才发现的:
_holes 同一条规矩)。112 件实测:45 件的孔总数变了,全是非钣金件;钣金口径的孔数逐件一字未动 (钣金件走的是钣金那条孔识别,本就不受影响)。改完 50 件非钣金件里两个口径 逐件一致,其中 3 件有孔。列里也不再把倒圆写成"孔",改叫"圆角"。
此前只有折弯有编号(明细表里那列 #),孔与切割只有"按类型×规格分组"的汇总 ——想指着说"这个孔"就没法说。现在三个系列各带前缀:
| 前缀 | 指什么 | 为什么单独一列 | |---|---|---| | B | 折弯 | 号沿用原来的 #,不重排——用户已经在用它 | | H | 孔(圆孔/沉头/沉孔/盲孔/翻边) | 车间按规格换冲头、换钻头 | | C | 切割轮廓(腰形槽/矩形/异形) | 走轮廓刀路,与钻孔是两件事 |
排序不用检出顺序(那会随实现细节漂移),而是沿零件最长边从一头数到另一头 ——跟人拿着图纸从左往右点数是同一个顺序。同一份文件反复解析,号一模一样; 这是编号唯一的用处所在,也是测试里钉得最死的一条。
H12 回车两边同时定位。/api/analyze 与导出——所以「我说 H12」这件事对 AI 也成立。编号写进落盘的报告 JSON,因此 labels.py 计入缓存指纹。(起初把它排除了, 理由是"编号只是呈现"——那是错的:排除意味着改了排序规则后,旧缓存里的号是老的、 新算的是新的,同一个号在两件之间指向不同东西,恰恰把编号唯一的用处毁掉。)
用户报 ETS01835 多了两道折弯,指的是"一个大孔的两条横边"。查下去是一个腰形 大孔四周翻了一圈边:两条横边(内 R2、长 70)+ 两端两段半圆(内 R35),中间由 6 个圆环角面接起来,围成一个闭环。
四段逐个看没有一处可疑——都满足 Δr=t 同轴对,都通过轴向闸门(轴与相邻墙 法向夹角 0°,标准折弯特征)。只有连起来看才露馅。所以新判据不看角度也不看 半径(那两样与真折弯一模一样,本来就分不开),只看拓扑:
压弯的折弯线是一条直线,两端到料边为止。想把同两块板在四处折起来还围成 一圈,压弯做不到——那要求板料自己断开再接上。能围出闭环的只有模具。
把折弯面与圆环角面连成图,连通块里边数 ≥ 点数即含回路 → 判为模具拉伸/翻边 (闸门 G1,排在所有单点判据之前,因为它是唯一逐个看看不出来的)。
一并修掉的连带错误:
total_cut_mm 与 outer_contour_mm 一直是空的; 修完能算了(2575 / 4718 mm)。不计折弯不等于不要钱:翻边是独立成形工序、要模具,改报 DRAWN_RING(info)。
108 件真实来料逐件对比:只有这一件变了,其余板厚/折弯/展开/孔/置信度一字未动。 另有一条专门防过拟合的测试:角件、U 件、Z 折、包边、圆孔翻边、卷圆件共 8 个夹具 的折弯道数,逐个按改动前实测值钉死——角件的两道折弯在角上由圆环面连着, 拓扑上离"闭环"只差一步,是最容易被误伤的形状。
页面里的相对路径按服务根算,账号 admin 的件出来是 admin/x.stp;而 resolve() 把它接在 root/admin/ 后面找 root/admin/admin/x.stp。目录页的按钮 没事(它另算了一份正确的 rel),所以从目录页点进详情一切正常,只有详情页自己 生成的链接是坏的。抽出 _Store.url_rel(),两处说同一套话。
deploy/restart.sh。此前手敲的 pkill -f "m server ..." 会匹配到发起重启的 那个 shell 自己(命令行里就有这串字),新进程还没起来旧的连同脚本一起被打掉 ——站点直接 502。改成按端口找 PID。
把 0.18.0 的机加工口径放到 108 件真实来料上量了一遍,量出两处硬伤。
折弯内圆角、棱边倒圆同样是凹圆柱,而且又长又细:一条 200 mm 长的 R3 折弯被算成 "φ6 深 200 的孔",深径比 33——在 2 mm 板上纯属胡说。真实来料里 58 件钣金有 41 件被报「深孔」,最离谱的一件算出深径比 536。
判据是包不包住轴线:孔壁绕轴一圈(拆成两个半圆柱时合起来也是一圈), 圆角只扫过约 90°。按同轴各段扫掠角之和卡 270°。修完之后:钣金件的最大深径比 从中位 23 降到 0.37——2 mm 板上的 φ8 通孔本来就该是 0.25。
顺带修掉同一处第二个错:同轴 ≠ 同一个孔。折弯件两片法兰上对齐的两个孔, 轴线是一条、中间是空的,整段跨度当深度自然荒唐。只把首尾相接的段并成一个孔。
改完在 108 件上重测:钣金件 0 条机加工提示,铣削件内圆角 16%、朝向数 4%、 去除率与深孔 0%。去除率一条这批没触发是对的——它们实心得多。
三处修复各有一条回归测试,逐条还原验证过会红。
用户说这批件"大概率机加工,但说不准"。既然说不准,就不押注——把机加工那条 路补齐,两套口径并行给。
metalmaster/machining.py)钣金件有一整套口径(板厚/折弯/展开/切割长),而铣削件此前只拿得到外形、体积、 估重——不足以报价。铣削的成本动因是另一套,现在都给:
| | 为什么是它 | |---|---| | 毛坯块 长×宽×高 + 毛坯重 | 买多大一块料——材料成本按它算,不是按成品重 | | 去除率(削掉的/毛坯) | 铣削机时的主要动因 | | 加工面朝向数 | 复杂度下限。长方块基线是 6(上下左右前后) | | 最小内圆角 | 卡住能用的最小刀具直径——越小刀越细、走刀越慢 | | 孔清单:直径×深度×深径比 | 深孔要啄钻、要加长刀,与浅孔不是一个价 |
四条提示(去除率 >80%、内圆角 <R1、深径比 >5、朝向数 >6)都是加价项信号, 一律 info、不扣置信度——它们不是"分析出了问题"。
这套数不依赖"是不是钣金",任何零件都算得出。工艺判不准时,两套数同时在 手上,比逼着人二选一有用。可作表格列、可导出。
"加工面朝向数"最早按 >3 报警,结果一块平板都触发——平板、L 件都恰好是 6, 那就是任何长方块的六个面。等于没信息。基线改成 6,超过 6 才说明有斜面/多向 特征。写成断言,免得日后又收紧。
一件铣削件此前顶着"钣金报价统计表",并写着"折弯 0 道、孔切割周长 0 mm" ——那些字段停在流水线的初始值上,读的人会当成"一块没折弯的钣金"。这与网页表格 里"不适用"那次修的是同一个问题,终端渲染器漏掉了。现在标题随口径走, 钣金专用行(折弯/孔切割周长/孔阵列)在非钣金件上不再印。
站在"这批图纸要拿来报价"的角度回看,发现板厚被测错了——而这是最坏的一种 错:数看着完全正常。
62 件待复核的件里,报价要的数其实齐全(板厚/外形/估重/孔数/折弯数 62/62), 只有切割总长 0/62。顺着这条线查:切割长来自展开轮廓 → 展开说"无面板可展开" → 面板配对为 0 → 在检出的板厚上根本配不出两张皮。
再往下:一件 47×32×20.6 的料,两张大面实际相隔 15.00 mm,V/15 ≈ 大面面积 (1563 vs 1499),是块规规矩矩的 15 mm 下料件。而工具报的板厚是 8.72 —— 某个台阶/槽宽的间距。板厚一错,材料规格、毛坯、切割长全跟着错。
根因是产品假设:t_max_mm = 12.0("钣金"的习惯口径)。用户这批 15 mm 的件 从一开始就不在候选区间里,通道 A 只能在 12 以内挑一个。
板厚必须是"两张皮之间的距离"(facetable.has_skin_pair)。通道 A 统计所有 平行平面对,台阶深、槽宽、凸台高都在投票里,碎面一多就能盖过真正的两张大皮。 选出的 t 上若配不出一对相对的皮肤面,就按"皮肤法"重新量。皮肤不一定是平面—— 卷圆件的两张皮是同轴圆柱(通道 B 的 Δr=t),这条也认。
厚板可表示:新增 t_max_plate_mm = 25.4(1 英寸),只用在皮肤法兜底那条 路。不直接抬高 t_max_mm——它是绝对长度,抬高会让更多无关间距进入投票, 结论就跟零件的绝对尺寸挂钩了(同一个件缩小一半结论会变,均匀缩放的 metamorphic 门禁当场抓出)。兜底那条路要求先配得出皮肤,无关间距进不来。
结果:这批件的板厚 52/65 稳定读出 15.0。
中途加过一条"证据 argmax 否决":_kind_evidence 已经算出各假设能解释的表面积, 谁强听谁的。它在几件实心块料上看着很对(真实钣金件里最强对手/钣金证据 ≤0.10, 块料 1.60,隔着 16 倍),却把 roll_section(卷圆件)判成了回转体——卷圆的 钣金件本来就有大量共轴回转面,"看着像回转体"是它的正常样子。
三个数据点上成立的规律,不足以推翻一个成立的分类。已撤回,并留了反向断言。
全套 764 项。
用户传上来 65 件真实生产图纸(SF26130 批次),结果是 11 件直接崩、17 件 「分析失败」、零件自动通过。逐个查下来是三个互相独立的洞,都不是"这批件特别 怪",而是算法与语义本身有问题。
from_masses 的 min_share 是给噪声候选设的闸。厚板的平行面距离簇很多, 流水线最终采用的那个未必票数最大——占比低于 5% 就被滤没了,随后"采用项必须在 候选中"的不变量直接抛 ValueError,整件分析失败。
改为:先按闸筛,再把采用项补回来。补回之后仍找不到,才是真正的标签词表 不一致——那是代码 bug,仍然抛。(这条保护不能连着一起弄没,反向断言钉住。)
_score 的注释写着 failed = "拿不出结果",代码却只按置信度阈值分档。于是一批 11 mm 厚板——板厚、外形、孔全都算出来了,只因决策记录多、扣分多——被标成 「分析失败」。用户看到的是"这工具在我的图纸上跑不动",而答案其实是全的。
现在三个词各归各位:failed 只留给真的没有结果(文件读不出形状、或连外形都 没有);有结果但把握不够一律 needs_review,让人去看那几条标记。
一件 37×19×17 mm、「厚 11.8 mm」 的块料,薄板性 0.53 勉强过闸就被判成钣金件 ——尽管它自己的棱柱证据(1409 mm²)比钣金证据(879)还大。_kind_evidence 早就把各假设能解释的表面积算出来了,结论却没用它。
改为证据 argmax。分离很干净:真实钣金件里「最强对手/钣金证据」≤ 0.10 (多数 0.02),这块料是 1.60,中间隔着 16 倍——所以这是 argmax,不是调出来的 阈值。
三处修复各做了突变验证。新增 tests/test_real_batch_regressions.py 9 项, 全套 763 项。
补上一个明显的窟窿:没法删文件。
支持 ZIP 之后这事变得要紧——一次拖进来几十件,传错一个包就只能一直挂着。 origins.prune() 当初就是按"文件会被删掉"写的,但系统里根本没有删除入口。
工具条上加「删除」(勾了文件才可点)。不可撤销,所以三件事都做到:
store.resolve,越权与穿越在那儿就拦住了;点删除会先弹确认,并把具体文件名列出来(最多 8 个 + 总数),不是只给个数字。
表头那个「全选」勾的是所有行,包括被筛选隐藏的。之前只影响导出(多出几件 屏幕上没显示的),现在有了删除就要命了——会直接删掉看不见的文件,且不可撤销。 改为只作用于看得见的行;半选状态的计算也跟着只数可见行。
ZIP 来源可追可筛;README 与介绍页按现在的产品形态重写。
上一版把目录并进了文件名,但包名丢了——传完就查不回"订单2026A.zip 里到底 有哪些件"。(包名不适合再塞进文件名:名字会长得没法看,而且常与顶层目录重复。)
新增来源台账 server/origins.py:每落一件记下来自哪个包、包内原始路径、 上传时间,按账号一本 JSON,落盘 0600。于是:
未解析的件也显示来源列:刚传完一整包还没来得及解析时,"这件是哪个包来的" 恰恰是最先想看的。只有真正需要分析才得出的列才留空。
"从哪个 ZIP 来"是上传时的事实——计算包只拿得到一个 shape,永远不可能知道, 所以不塞进 metalmaster/schema.py。新增 server/columns.py 把计算包的字段目录 与系统层这几列拼成一份,界面与导出都走它,两处不必各写一遍。
支持上传 ZIP:自动解包、挑出 STEP、把目录并进文件名。
订单A/支架/左板.step → 订单A__支架__左板.step。系统里平铺存放,所以来源 必须写进名字里——否则一包传上去,谁是哪个单子哪个部件就全糊了。列表里、导出的 表里都一眼看得出。
上传口按内容判而不只看后缀(PK\x03\x04),有人把 .zip 改名成 .step 传上来也认得出,不会当成一个坏 STEP 存下。
路径穿越(Zip Slip):条目名写成 ../../etc/x.step 或 C:\... 就能解到 目标目录外面。这里从不把条目名当路径用——只取各级名字、逐级清洗、再拼成 一个平铺文件名。穿越在结构上就不可能,而不是靠事后检查漏没漏。7 种写法逐个测。
解压炸弹:条目数、单件大小、解压总量三道硬闸,任一超限整包拒绝—— 不是"解一半",半包数据比没有更坏。
压缩比这条最值得说:最早写成"超过 300 倍即拒",结果一个 1.5 MB 的普通 STEP 压了 339 倍当场被判成炸弹。STEP 是高度重复的文本,几百倍再正常不过。有害的 是绝对膨胀而不是比例——所以比例只对大文件(>64 MB)生效,真正兜底的是总量。 这条取舍写成了断言,免得日后有人"顺手收紧"又误伤真实图纸。
文件名编码:包里若没置 UTF-8 标志,zipfile 按规范退回 cp437 解, 中文就成了 ┴π╝■/╓º╝▄A.step。判据很干脆——真正的中文名 encode 回 cp437 会 失败(cp437 里没有汉字),所以能编回去的就是被误解过的字节,重新按 UTF-8 / GBK 解一遍。GBK 那条只在解出汉字时才采纳,否则 café.step 这种也能编回 cp437,硬解会把好名字改坏。
_1 递增;非 STEP 条目跳过并给出理由,前端把"解出 N 件、 跳过 M 项"如实告诉用户。tests/test_unzip.py 36 项 + 端到端 5 项。全套 740 项。
上一版新加的版本号断言用了 tomllib —— 那是 3.11 才进标准库的,而本项目 requires-python = ">=3.10",CI 的 py3.10 作业当场挂掉(py3.12 是绿的,所以 本地跑不出来)。改成直接读文本,与 tests/test_layering.py 里读 pyproject 的 做法保持一致。
修 0.12.0 里我自己造的两个问题。
0.12.0 做视觉时,为验证"改样式不掉缓存"临时改了 htmlreport.py 一行, 验证完用 git checkout 还原——连同那次未提交的设计令牌重写一起还原掉了。 于是发出去的版本里,server/pages.py 引用着 19 个不存在的 CSS 变量。
浏览器对无效值的处理是"当这条声明不存在",所以不报错、不空白,只是背景、 圆角、阴影统统悄悄消失——看着就是"样式变丑了"。lint 和渲染测试都抓不到。 令牌已恢复,并补上两条断言:用到的变量必须都有定义、深色模式必须覆盖 全部颜色令牌(漏一个就会在深色下留一块浅色)。
__version__ 原先在 __init__.py 里,而 __init__.py 在代码指纹内——于是每 发一版,几十件的分析结果全部作废。版本号跟计算结果毫无关系。
单拎到 metalmaster/_version.py 并在指纹中排除。pyproject 的 attr 也要跟着 指过去:setuptools 是静态读取的,跟不进 from ._version import __version__ 的转发(指错了装包时直接 AttributeError),这条也写了断言。
全套 699 项。
视觉整体过一遍;顺手修掉一个每次调样式都会咬人的设计缺陷。
落盘缓存的钥匙里带着 metalmaster/**/*.py 全部源码的哈希——包括 htmlreport.py 这种纯渲染模块。于是每次动一下 CSS,43 件的分析结果全部作废、 要重算一遍。这次做视觉正好撞上。
指纹改为只盯会改变落盘内容(报告 JSON + 缩略图)的模块。排除掉的是 htmlreport / viewer / cli / mcp_server / table / schema / fixtures ——前两个是渲染,中间两个是入口,后面两个是读取时才作用的(落盘存的是 status: "ok" 这类原始枚举,中文标签和字段目录在读出来时才套上去,改译名下次 读就变了,不必重算)。
方向仍然是安全的那边:新增模块默认计入指纹,忘了登记的代价是多算一次, 而不是拿旧版本的数糊弄人。12 个模块逐个参数化测试,正反都钉住。
(本次因为指纹口径本身变了,缓存不可避免地失效一次;之后调样式不会再掉。)
设计口径:一套灰阶 + 一个强调色(烧橙)。强调色只出现在可操作与 需注意的地方——链接、主按钮、选中态、告警;数据本身一律中性色, 免得一屏都在喊。浅色底改成暖白 #fcfcfb 而非纯白,深色底带一点蓝, 长时间看图纸不刺眼。新增 --surface/--border-soft/--shadow 等层次令牌与 统一圆角。
tabular-nums 对齐;clamp() 随屏 缩放,特性卡 hover 微微抬起;:focus-visible 焦点环(键盘可达性,也是细节)。交互优化,按"实际用起来卡在哪"来定,不是按"该有什么功能"。
/ 聚焦、Esc 清空;页头实时显示"显示 N / 共 M";parts.append("<h2>…")——将来加新章节 自动就有,不会漏;j / k。逐件核对几十个零件时, 不必每看完一个就退回列表再找下一个;挂上域名(HTTPS 反代)后的收口:网络面收窄、访客落到介绍页、标签页图标修好。
挂了 metalmaster.ymp1.yuanmu-ai.com 之后,后端没有任何理由再监听 0.0.0.0。 改绑 127.0.0.1,由 nginx 终止 TLS 后转发。防火墙本来就只放 80/443, 现在即便规则被清掉,进程也只在回环上。部署配置连同说明收进 deploy/。
有了可信反代,MM_TRUST_PROXY=1 才第一次成立——审计日志里终于是真实客户端 IP,而不是清一色 127.0.0.1。(默认仍关闭:没有反代时采信 X-Forwarded-For 等于让任何人往审计日志里灌假 IP。)
反代侧还补上了应用不方便做的三件事:Secure cookie(proxy_cookie_flags)、 /login 限速 10 r/m(把爆破挡在 Python 之外,顺带防"用 PBKDF2 烧 CPU")、 HSTS。上传体积与 MAX_UPLOAD_BYTES 对齐到 200 MB,免得大 STEP 被 nginx 抢先 413;读超时 3600s,因为 STEP 解析是同步的。
未登录访问 / 现在给介绍页,而不是把人一脚踢到登录表单——挂上域名之后, 第一次来的人打开的就是根路径。安全性不变:介绍页纯静态、不碰任何数据; 其余路径(/api/*、/part、/admin、导出)照旧一律拦下,测试里逐条钉住。
顺带把标志收进计算包的呈现模块(htmlreport.logo_svg / FAVICON / HEAD_ICON), server 直接取——之前 server 里另写了一份,是第二个真相源。报告页是自包含 HTML, 图标同样用内联 data URI,离线发出去也能显示。
四件事:非钣金件的数据口径、表格格式、解析结果落盘、品牌与落地页。
一块铣削件走不到钣金流水线,于是 bend_counts 停在初始值 {n_op: 0, …}、 thickness_mm 停在某对平行面的间距上。照原样填进表格,那一行会写着 "折弯 0 道、板厚 5.88"——读的人当成"一块没折弯的钣金"。喂给报价的 CSV 里 尤其危险:0 是会被求和、被筛选的。
新增哨兵 schema.NA,三种"没数"分开呈现:不适用 / —(没值) / 0。 表格里灰底斜体、排序沉底;CSV 与 Excel 写"不适用"而不是留空。
但"孔总数"不该不适用——铣削件当然有孔。分组级的一刀切太粗,改成字段级: 孔总数 / 不同孔径数 / 孔规格分组退回通用几何那份同轴圆柱清单(钣金件上这条 与钣金口径的孔数相互印证,都是 6);真正落料/冲床专有的孔切割周长、孔阵列才标 不适用。另新增两个通用字段(孔状特征数、回转特征清单),非钣金件那一行不再几乎 是空的;面数也改为在通用几何里兜底。
not_sheet。内存缓存进程一停全没,所以每次更新服务,目录页又全变回"未解析"。新增 server/diskcache.py:结果写成明文 JSON,每条带一把四段钥匙—— 计算代码指纹(metalmaster/**/*.py 全部源码的哈希)、配置指纹、源文件 大小+mtime、格式版本,全对上才认。
那个源码指纹是整套设计的支点:改界面不掉缓存、改算法必掉缓存自动成立, 不依赖谁记得手动清。缓存用错比重算慢糟得多——读的人不会知道自己看的是上个版本 算出来的数。「重算」会连磁盘那份一起清(否则清了内存又从磁盘读回旧结果, 重算就成了假动作)。落盘 0600、不进 STEP 清单、写失败不影响分析。
/api/analyze 与导出改走磁盘:重启后导出几十件是秒出,不再重算一遍。
每行一张等轴测消隐线画。详情页那套写法一张 90 KB,几十行同屏会把页面撑爆; 列表这版只画可见轮廓、坐标归一到 100 格取整去重、所有折线并成一条 path, 93.9 KB → 5 KB。跟着结果一起落盘,重启后照样有。
标志是一条等厚料带折出来的 M:折弯半径是圆角接头,料厚是线宽——正是本工具 读几何的方式(看板料截面,而不是看形状像什么)。内联 SVG + currentColor, 浅色/深色自动跟随,同一份还当标签页图标。assets/ 下另有带展开虚线的版本与 横版字标。新增公开介绍页 /welcome(纯静态,在登录闸门之前,不碰任何数据)。
全套 680 项。
界面按"统一、简洁、清晰"重做目录页,并把导出打通到逐列与 CSV。
目录页的列与导出的列现在是同一份东西——都来自 metalmaster/schema.py 的 60 个字段。点「列」从 9 个分组里逐个勾选,勾完即刻决定表格显示与导出内容, 不会再出现"表里的数和导出的数不一样"。
?cols=a,b,c),可直接把某套列的视图发给同事;POST /export 接受 c=<字段key> 逐列指定(老的 g=<分组> 仍可用);fmt=csv)。写出器 server/csvout.py 仍只用标准库, 且写 utf-8-sig——没有 BOM 的话 Excel 打开中文表头是乱码, 这是个必踩的坑;行尾 CRLF(RFC 4180)。逗号与引号按 CSV 规则转义, 真实图纸名("Describe Document - AGS000190-01, PLATE, SEI, 00.1") 逗号成堆,不转义会把一列撑成好几列。报告缓存每条含完整 HTML(真实件 0.5 MB),所以上限只有 24 条——超过之后先分析 的件在目录页又变回"未解析",列表页等于没数可看。而目录页只要那一行扁平数据 (几百字节)。两者拆开:报告缓存照旧 24 条,摘要缓存放到 5000 条。实测分析 30 件 (报告缓存只有 24),目录页 30 件全部有数。
另加「解析全部(N)」:顺序解析尚未解析的件并显示进度(第 i/N 件 + 件名)。 串行发请求——服务端 OCCT 本来就是串行的,并发只会排队还看不出进度。
默认列此前在 JS 里硬编码了一份,是第二个真相源;改为渲染时从 schema 注入。 JS 语法门禁也改成检查真正下发到浏览器的那份(含注入结果),而不是模板。
一件"需要人工复核"的平板(AGS000190-01)查下去,牵出四处同一类根因的缺陷: 判据在证据缺失时的倒向,和 OCCT 轴向取反带来的区间错位。四处都不改毛坯尺寸、 不改折弯数,却会让平板变卷圆件、一个孔数成两个、切割长报成三倍。
平板上的抽孔领口(翻边孔)经圆环面过渡到板面、顶端是垂直于轴的环面—— 一堵"相切墙"都没有。G2 轴向闸门里那句 max(..., default=0.0) 于是在零证据下 直接投给"普通折弯",R53.8 的领口成了卷圆:整件被当作卷圆件(外轮廓标为不可展、 切割总周长报不出来)、BEND_NO_WALL 报警、状态掉进 needs_review。
没有局部证据就换全局证据,但别换判据——物理上始终是同一句话:折弯是把料绕一根 躺在板面里的轴转过去。这正是 unfold.unrollable 用的判据,现在提到 facetable.axis_lies_in_a_panel,两处说同一套话。
OCCT 写 STEP 时,哪一侧是 +u 由建模历史决定,不是几何量。于是抽孔领口的内外壁 区间读出来是 (-12,-6) 对 (6,12),凭空多出 12 mm 间隙——被轴向间隙判据拆成 两个孔;压窝沉头孔的 φ7 孔壁与两张锥面同样对不齐,一个孔报成 countersunk φ22.75 + round φ7 两个。深度也跟着错(6 mm 的领口报成 24 mm)。
新增 geom.oriented_interval,比较任何轴向区间之前先翻齐;pairing 里那份 同样的逻辑改用共享实现,不留两份。
通孔在两张皮上各有一圈;沉头/翻边孔的两圈还不一样大。旧写法每个特征只认领一圈, 剩下那圈成了孤儿——被当作"有内环却没有对应圆柱",误报 LOOP_CYL_MISMATCH (NURBS 退化嫌疑),孔数还多算一个。改为由内环去挑特征,一个特征可以收下 属于它的好几圈。
判归属用拓扑连通而不是距离:抽孔领口的内环离孔壁 6 mm(隔着圆角,该合), 而 ets01835 上主板的开口离其后方小板上的槽只有 3.5 mm(隔着空气,不该合)。 距离在这里根本分不开,走不走得到才是"是不是同一个开口"。
落料时切的是最小的那个孔——沉窝的锥面、翻边的领口都是之后成形出来的,不是切出来 的。取大口会把一个 φ7 压窝沉头孔的切割长报成 π·22.75,是实际的三倍多。
翻边孔在成品上是 φ107.6,但毛坯上是个更小的预冲孔——那圈料被拉上去成了领口。 直径取决于翻边工艺(拉伸量、是否变薄),几何推不出来;但体积守恒给得出它应有的 面积:展开净面积恒等于 V/t。只有一个这样的孔时这是一元方程,解得出来就按它 画进展开图与 DXF:AGS000190-01 上是 φ91.8。
此前展开图上一个孔都没有——DXF 直接拿去下料就是一整块板,比画个近似的更危险。
两条互相独立的算法,报成区间,并交人工裁定。 一条用体积(毛坯体积 = 零件 体积 → 毛坯面积 = V/t),一条用长度(中性面弧长:圆角展开 + 直壁高,就是算折弯 展开那一套)。本件给 φ91.8 与 φ94.4,差 2.8%——互不依赖还能对上,说明两条 都没算错。画进 DXF 的取较小者:孔可以再扩,料补不回来。
方向说清楚:两条都假定料厚不变,所以都是下界。真实翻边会把领口壁拉薄, 同样的料铺得更开,实际预冲孔只会更大(本件 CAD 恰是按等厚建模的:Δr 处处 3.00、顶端环宽也恰好 3.00)。缺工艺数据时无法给这两个值排序,故按 p=0.5/0.5 记为待裁定决策进决策表,供人按自家工艺选定或改写;该件 status 也因此为 needs_review——复核内容就是这一个数,几何本身已全部确定。
(上一版把这句写成"不计变薄",方向是对的,但把恒等式本身说成了近似。恒等式 是精确的:塑性变形体积守恒,毛坯是等厚的,所以毛坯面积恒等于 V/t。不确定性 不在这条式子,而在"CAD 体积是否等于真实毛坯体积"——等厚建模时 CAD 偏大。)
三条自我约束:
flanged_hole 正是 这种——它是把一段管子并到板上做的,材料本就不守恒。手算核对:φ91.8→φ107.6 之间那圈料 2474 mm²,领口中面展开 2085 mm² 加根部圆角, 量级对得上。另有量纲门禁:整件缩放 k 倍,解出的预冲孔直径必须也是 k 倍。
LOOP_CYL_MISMATCH,ets01835 孔数 41→37(4 个压窝沉头孔 不再各数两遍)且 UNFOLD_AREA_MISMATCH 一并消失。没有一件变差。tests/data,新增 tests/test_formed_features.py(10 项, 含刚体变换不变性、缩放协变性与"两块板上的开口不许合并"的反向断言)。 全套 627 项。对新上的账号体系做了一轮对抗性测试——把服务当敌人打,不测"正常用法下权限 对不对",测"被人存心搞时会不会破"。119 条攻击里 4 条打穿,都已修复;每条修复 都做了突变验证(把补丁摘掉,对应的测试必须变红),有两条测试正是这样被发现 写成了假绿的。
1. HTTP 响应拆分(最严重) — 上传时的文件名是查询参数,攻击者完全可控。 evil"\r\nX-Injected: yes\r\n\r\nPWNED.step 存到磁盘后,下载时被内插进 Content-Disposition,CRLF 直接劈开响应头:能加任意响应头,\r\n\r\n 之后还能写一整段自己的响应体——即从本站源提供任意内容。低权账号即可发起。 修法两道:文件名闸门拒收控制字符/引号/路径分隔符;Content-Disposition 改用 RFC 5987 构造,不再内插。顺带修好了中文文件名——原先直接内插会在把响应头按 latin-1 编码时抛 UnicodeEncodeError,变成 500。
2. 用户名 .. 逃出服务根 — 用户名即目录名,而 .. 完全符合旧的 "只含字母数字下划线点连字符",root / ".." 一步跳出服务目录。正则改为首字符 必须是字母或数字,顺带挡掉 .hidden 与 -rf。
3. 账号库世界可读 — .metalmaster/ 是 0775、JSON 是 0664,里面装着 口令摘要和当前有效的会话 token。同机器上的任何本地账号读一眼 sessions.json 就能冒充任何人。改为 0700/0600,且先 chmod 再改名, 不留可读窗口。
4. 登录无节流 — 既能慢速爆破,也能反过来用 PBKDF2 的 20 万轮烧 CPU (未认证即可发起)。按账号与来源 IP 两把尺子各卡一次,冷却翻倍封顶 15 分钟, 返回 429。
_resolve_or_404 身份缺失时的默认值是"解析到服务根"。今天所有路由都会先 设身份所以走不到,但只要有人加一条路由忘了设,就是一次彻底的隔离失效, 而集成测试会全绿。改为 403。这条走不到的分支专门写了单测。_ip() 无条件采信 X-Forwarded-For,等于让人往审计日志里灌假 IP。 改为默认用 TCP 对端地址,确有反代时 MM_TRUST_PROXY=1 开启。伪造/畸形/空会话 token、Cookie 名前缀混淆、账号体系下的旧 ?t= 通道、会话固定、 登出后复用、过期会话、停用后复用、16 种路径穿越写法 × 5 条读路由、跨账号读与 导出、/admin/* 的大小写与编码绕过、文件名与显示名的存储型 XSS、登录页的反射型 XSS、错误页泄露服务端路径——共 115 条,全部拒绝。
从"一个能看报告的本地小站"变成能给一队人用的东西:账号、结构化导出、目录页。 同时把计算与系统两层在代码上彻底分开。
metalmaster 是纯计算包,server/ 不随包发布webapp.py 搬出计算包,拆成 server/{app,auth,pages,xlsx}.py。计算包从此 不 import 任何 server 里的东西——tests/test_layering.py 把 server 从 sys.meta_path 里屏蔽掉再 import 整个包来证明这件事,光靠约定不算数。 pyproject 只收 metalmaster*;版本号改为从 metalmaster.__version__ 取 (此前 pyproject 里手抄了一份,已经漂到 0.5.3 对 0.7.5)。
新增 metalmaster/schema.py:60 个字段、9 个分组的唯一定义源。 界面上的勾选项、Excel 的列、/api/schema 全部由它生成,不再各写一遍 (test_schema_catalog_matches_export_headers 盯着这一点)。 提取器只读分析结果字典,绝不重算——导出与页面显示必然一致。
POST /export:勾选的文件 × 勾选的字段组 → xlsx。写出器 server/xlsx.py 也是标准库现写的(zip + 几张 XML)——为了导一张表引入 openpyxl, 不值当破"除几何内核外零依赖"这条线。
<服务目录>/<账号>/,越权与路径穿越一律 404;/admin:建号、停用、看每个账号的登录与操作记录;--no-accounts 保留旧的纯令牌模式(本机自用)。那条路径下没有后台也没有 会话,页面就不显示退出/后台链接——摆着点了就 404 的链接比没有更糟。
一行一件,直接给出状态、形态、板厚、外形、折弯数、孔数、毛坯尺寸; 0 也如实显示 0(此前 falsy 被渲染成"—")。勾选即可导出,每行有「重算」。
return 写在循环里);新增 test_server_accounts.py(27 项:账号库不变量 + 真 HTTP 打权限边界)、 test_server_pages.py(11 项:标签闭合、转义、有 node 就过一遍 JS 语法)、 test_layering.py(31 项)。全套 500 项。
这一轮是系统性的精度与鲁棒性排查:语料只有 46 件且含重复,靠更多真实件 验证不现实;改为对夹具施加成套变换,检查不变量。不变量一破,就说明算法 依赖了某个偶然条件——这比"在这几个样本上对不对"更能防住过拟合。
新增参数化夹具 make_bend(θ),15°–170° 扫一遍,两条独立判据同时卡: 展开长与构造参数手算差 ≤0.005 mm,中面口径体积恒等式 全部精确 1.0000。
(过程中又踩到夹具坑:两条腿若写成正切向,大角度时会插进弧内,OCCT 建出 自交的无效实体——看着像"算法在大角度上失准"。夹具生成器现在自带 "实体必须有效且体积与理论一致"的自检。)
压包/凸包/百叶窗拉伸了材料——毛坯比展平后的轮廓大,几何上看不出来。 新增球冠压包夹具,验证三条互相独立的判据都报警:未解释面积 19.5%、 体积恒等式 0.795、毛坯利用率不自洽;状态不为 ok,不会被自动报价流程误用。 任何一条单独失效,另外两条仍能兜住。
tests/test_robustness.py 现 81 项。全套 415 项。
一块面板的两张皮都是材料外向法向,必然反向。判据里漏了这一条, "带符号"检验就变得不对称——(w,v) 通过而 (v,w) 不通过——于是候选集合 随面的枚举顺序而变。welded_bracket 实测 12 次乱序给出 3 种不同的毛坯 (85.97×60.97/2 片、60.97×55/3 片、60.97×55/4 片)。 STEP 重新导出常会改面序,这在真实使用中随时可能踩到。
补上"外向法向必须反向"这条零阈值物理约束后:16 个夹具 × 12 次乱序 0 次不一致;welded_bracket 收敛到唯一且物理正确的答案 (85.97×60.97 = corner_bracket 的毛坯 + 对接板 = 2 片), 体积恒等式 0.9849 → 1.0000。
超出支持区间(板厚 0.3–12 mm)时会测到某对无关平行面的间距——0.1× 缩放的 L 件真实 t=0.2 mm,却报 t=2.8 mm。薄板性判据虽然兜住了(判为 not_sheet), 但 JSON 里留着一个能被误用的数。终端表现在写明它是什么: "—(非钣金口径不适用;测得平行面间距 2.80 mm,未通过薄板性校验)"。
tests/test_robustness.py(55 项)同一零件换个"写法",结论必须不变——不变量破了就说明算法依赖了偶然条件:
not_sheet 并给通用几何事实, 不猜一个数往下算。全语料精确命中 1.0000:36 / 46(±1% 内 42)。
卷圆的展开弧长就是 θ·(r + K·t)——与折弯同一个公式。它只是不进 n_op (卷圆是另一道工序,单独计 n_roll),那是工序口径,与展开无关。 此前把卷圆整个排除在展开之外,卷圆件报的是折叠后的外形包围盒: roll_section 报 87.7×47,真值 139.9×30。修正后毛坯与手算 leg + θ(R+K·t) 精确一致,体积恒等式 0.199 → 1.0000。
但绕孔轴的 360° 卷不算:抽孔/翻边凸缘是绕垂直板面的孔轴拉出来的, 它在毛坯上对应的是更小的预冲孔,不是一段能展平的弧。判据是轴的朝向—— 可摊平的卷圆轴平行于板面,抽孔凸缘轴垂直于板面,分得很干净。 混为一谈会让整件的展开算崩(AGS000190 的毛坯一度变成 None)。
make_roll 原来用手搭闭合轮廓拉伸:150° 的弧配上切向直腿,那条线会自交, OCCT 建出的是 IsValid()==False 的实体,体积只相当于约 100° 扫掠。 夹具坏了却看着像算法的错——体积恒等式因此永远对不上。 改用回转构造(截面矩形绕轴转),体积与理论差 0.00000%,并加了 "实体必须有效且体积与理论一致"的门禁。
只接到一块面板的折弯片(卷圆件的弧就是这样:一端接切向直腿、另一端悬空) 此前被跳过。现在与"封闭截面被切开的那道弯"走同一条路——材料接在该面板外侧。
全语料精确命中 1.0000:35 / 46(±1% 内 41)。剩余偏差只剩两类: 抽孔预冲孔径(需翻边工艺)、拼接件的贴合面。
体积恒等式(中面展开净面积 ≡ V/t)当探针,一口气挖出三个各自独立的缺陷。 全语料精确命中 1.0000 的从 25 件增到 34 件(±1% 内 40/46)。
ax_interval 是各自沿自身 axis_dir 投影出来的。同一道折弯的内外两张圆柱面, OCCT 给的轴向可能相反——那时两个区间互为相反数([2.5,67.5] vs [-67.5,-2.5]), 看起来完全不重叠,配对被否掉,整道折弯就漏了。 AGS001938-04 因此少一道 90° 折弯:展开面积差 5%、面板图还断成两块被判为拼接件; 而它的左右对件 -03 因为两个轴向恰好同向就没事。修正后两件完全一致 (n_op=5、1 料片、毛坯 151.1×119.9)。
裁剪用的是无限半平面。面板上有两道轴向不平行的折弯时,第二道弯的切线会把 属于第一道弯那半边的材料一并切掉——corner_bracket 的底板因此少 250 mm²(6.3%)。 而带圆角的折弯根本不需要裁:皮肤面在 B-rep 里本来就止于切线(内外两张皮的 切点是从轴引出的同一条垂线的两个足,面内位置相同)。只有尖折弯需要裁——它的 外皮一直伸到尖角,多出 t·tan(θ/2)。 corner_bracket 0.9366 → 1.0000,welded_bracket 0.929 → 0.985, ets01835 0.947 → 0.976。
口形/帽形型材的面板图成环,展开必须切开一刀——但被切那道折弯的料仍然存在, 切开后它落在毛坯端部。此前只记 cycles 不出条带:AGS000020-19 的 4 道弯只出 3 条带,少 3346 mm²(5.4%)。补上后精确回到 1.0000。
配对修好后,AGS000190 的抽孔凸缘(内外圆柱 Δr=t)被正确识别为绕孔轴的 360° 卷圆——语义对,但毛坯上的预冲孔比成品孔小,我们没有建模。 UNFOLD_AREA_MISMATCH 现在给出差额面积及其等效圆直径 (该件 6624 mm² ≈ φ91.8),用的人可直接拿去核对预冲孔; 我们不把它画进 DXF——那要假设翻边不减薄。
r>0 时中性面是半径 r+K·t 的圆弧,让量 θ·(r+K·t)——这没问题。但 r=0 时中性面 不是圆弧,而是同样的尖角(距内表面 K·t):两条中性线在角内相交,路径长是 2·K·t·tan(θ/2),不是 θ·K·t。90° 时前者 2Kt、后者 1.571Kt。
这不是口径之争,是几何——K=0.5 时 2Kt 恰为 t,正是角部那块 t×t 方料的中线长, 可用体积恒等式(中面展开净面积 ≡ V/t)逐件验证:修正后 sharp_L 0.9926 → 1.0000、z_offset 0.9870 → 1.0000。全语料精确命中 1.0000 的 从 16 件增到 25 件(mixed_corner、ADX131200、SASV、sasv401880101 等一并归位)。
影响:sharp_L 展开长 57.26 → 57.60,z_offset 64.51 → 65.20(K=0.40)。
AGS000190-01 是块 3 mm 平板 + φ107.6 拉伸翻边凸缘(凸缘从板面拉到 z=12), 孔心离板面 9 mm > 1.5t,落不进展开图——此前直接 continue:展开 DXF 少一个 Ø107 开口、展开面积多 5.7%,且没有任何提示。现在按成因分开报告:
UNFOLD_HOLE_UNMAPPED(warn):轴向对得上但离板面太远 → 翻边/拉伸凸缘孔, 毛坯上应是更小的预冲孔,直径取决于翻边工艺,请人工确定;UNFOLD_HOLE_ON_BEND(info):轴不平行任何面板 → 斜孔或打在折弯圆弧上, 通常折后加工。已有 SLANTED_HOLE 说明工艺影响,这里不重复扣分。此前只有折弯件跑 build_developed,于是平板上的"孔没进展开图"这类问题 完全看不见。现在平板/单轴/多轴一视同仁——外轮廓、孔映射、体积自检都有。
st416-170-009 是个 φ17.7×7.8 的小机加件(外圆 φ17.7 + φ8 内孔 + 7 个锥面, 115 个小平面占 61% 面积)。它能"测出等厚"(两平行面相距一定距离),于是被当作 钣金件往下跑,得出利用率 223% 这种物理上不可能的数,最后报"分析失败"—— 调用方会以为程序出错,其实是零件不在钣金口径内。
三处修正,都用几何本身的判据:
2·(V/t) ≤ 表面积 恒成立。st416 的最强候选 t=1.25 给出 2V/(tA)=1.45, 意即"两张皮比整个零件的表面积还大"——几何上不可能,不予采用。 留 20% 余量的物理理由:焊接件的贴合面是内部面、不计入表面积,理想界限会被 轻微突破(welded_bracket 实测 1.001);实测分布把余量定死——真实钣金最高 1.001,越界的 st416 是 1.454,阈值 1.2 落在空隙中间。 不可行的候选照列在决策里并写明理由,不悄悄删——删了会把"变厚度嫌疑" 这条告警一起删掉。sheetness = 2·(V/t)/表面积 = 1/(1+2t/L)。≥0.5 等价于 "面内尺寸 ≥ 2t",这是"板"这个词的最低要求。实测 46 件真实钣金 0.667–1.00, st416 只有 0.31(面内尺寸不到 1 个板厚)。判为非板件后,钣金主线拒答。not_sheet 状态(ok | needs_review | not_sheet | failed)。 它与 failed 是两回事:分析成功了——认出不是板件、通用几何事实照常输出, 只是钣金口径不适用。终端表、HTML 徽标、CLI 退出码(4,不与 failed 的 3 混淆)、 装配逐件的 sheet_metal_applicable、MCP 输出全部同步。全语料 49 件复核:0 个 failed,46 个真实钣金件类型判断一个没变。
折弯工艺、二次工序、孔阵列、疑似螺纹底孔、零件名 此前在没有内容时 整行消失。报价的人因此无法区分"确实是 0"和"根本没算"。现在恒显示,并写明 是哪一种("无折弯工序(0 道)"、"无(孔径未命中螺纹底孔表)"…)。
原公式假设每条折弯条带的两条长边与相邻面板完全共享,减 4×折弯线长。 实际折弯线常比相邻面板宽(adx139058 的端部折弯线 40 mm,腹板只有 33 mm), 还可能出现条带一角埋进邻板内部的微小重叠。改为按交点把每条边精确切分、 逐段判定是否被其它多边形的闭区域覆盖——是精确算法不是采样近似, 包围盒剪枝后 build_developed 仍是 ~9 ms。
与独立实现的采样法并集周长逐件比对(已固化为门禁):9 件全部吻合到 0.2% 内; adx132980 由 1477.8 修正到 1481.8(数值解 1481.5)。
卷圆弧长没有展开,此时量到的"外轮廓"是折叠后的投影周长——roll_section 毛坯 87.7×47,却报 100 mm。宁可不给也不给一个看着精确、口径却对不上的数: 该行仍在,写明"外轮廓不可用:含卷圆,弧长未展开"。
outer_contour_mm 是 None)。 现在所有口径统一走展开几何:外轮廓 = 各面板/条带周长之和 − 折弯线处的内部拼缝(每条带减 4×折弯线长)。这是精确值,平板/单轴/多轴 一个算法;顺带修正了尖折弯件——旧的容斥估算沿用外皮伸到尖角的口径, sharp_L 偏大 4 mm(198.5 → 194.5,手算 2×(57.26+40)=194.52)、z_offset 偏大 8 mm;outer_contour_mm、blank_length_min/max_mm、blank_width_min/max_mm、 developed_area_ratio;终端表、HTML、CSV、JSON 四个出口口径一致。pierce = 1 + 孔数 假定只有一条外轮廓。拼接件展开后是几块独立料片, 每块都有自己的外轮廓、各要穿孔一次。改为 料片数 + 孔数 (welded_bracket 1 → 4)——激光工时输入此前偏低。
tests/test_formula_crosscheck.py,64 项)每条公式都用另一条算法或物理恒等式验,不拿实现对实现:
排除在"干净件"之外的三类反向也验:卷圆(弧面未展开)、翻边孔(领口是成形 不是切割)、沉头/沉孔(锥面不由激光轮廓切)——测试断言它们确实分叉, 免得排除名单退化成"凡是对不上就加进来"的清单。
判墙原先要求"面内外接次跨度 > 1.5t"。可 face_planar_extents 给的是外接 尺寸:材料厚度面只要拐个弯——型材端部的 L 形截面(139 mm² 却框在 45.7×29.6 里)、绕折弯圆角包过去的端板侧面(3.5 mm 高量成 3.77,阈值 3.75 擦边通过) ——外接框就很大,于是混进墙面。
改用等效条宽 = 2·面积/外环周长:长条形时它就是条带的真实宽度,与形状无关。 实测两族分得很开——真面板皮肤最窄 2.22t,材料厚度面全部 0.92–0.99t, 阈值 1.5t 落在空隙中间。全语料 113 张分歧面全部是厚度面,且分歧单向 (只会把面从"宽"降为"窄",不会反向)。
根上修对之后,三处下游补丁同时变成死代码,已全部删除:
SHARP_WITH_RADIUSED、DECISION_RISK、UNFOLD_DISCONNECTED、 UNFOLD_ORPHAN_FACE 一并消失,status 由 needs_review 转 ok;13 件 × 6 种姿态毛坯偏差仍为 0.0000 mm;变异测试确认门禁能抓住这条判据的回退。
build_developed 只要 ~10 ms, 两端各算一次可以忽略;--k-factor(车间知道自己用哪个),越界直接拒绝——中性层不会 越过板厚中面,K > 0.5 物理上不存在。折弯是塑性变形、材料不增不减,而 CAD 实体的体积对应的正是中面,所以 K=0.5 口径下展开净面积必须精确等于 V/t。这条判据不含任何超参数, K 取多少都掩盖不了"漏摊面板"(比值偏小)或"面板重叠"(比值偏大)。
实测:L / U / DYL / 包边 / 缺口耳 / 平板等 16 件精确 = 1.0000,两件真实客户件 0.9999 / 1.0002。偏离的都有已知成因:卷圆 0.199(弧长未展开)、翻边孔 0.959 (领口不在展开图)、多轴角件 0.93–0.95(两弯交角区未建模)、尖折弯 0.993 (尖角超出切线的部分被裁)。比值作为常驻指标 metrics.developed_area_ratio 输出,偏离 >2% 报 UNFOLD_AREA_MISMATCH(info 级,如实报告不改状态)。
adx132980 要对上图纸需 K=0.391(≈ 默认 0.40,合理);adx139058 的宽度需 K=0.568——超出物理上限。而体积恒等式证明我们的几何是完整的(1.0002), 展开图两端分别由翻边展开边和端板外边(纯二维尺寸、与 K 无关)决定。 结论:那 0.27 mm 用这个实体展开不可能得到,来自图纸方的余量或读数,不是算错。
用户给出两件的展开标准答案,逐一对照后挖出三个各自独立的缺陷。 三处都不是"调参",每一处都配了变异测试(把它改回去,门禁必须变红):
_place_child 把折弯轴映射到展开平面时只取了父面板的局部坐标 parent.local(),没有再过父面板自身的摆放矩阵 a2/b2。根面板的摆放是 单位阵、两者恰好相同,所以这个错误只在链条上第二道及以后的弯显形: 子面板会在展开图里多转一个角度。段差件转 37° 后毛坯从 64.5 变成 76.5。修复后与客户标准答案:adx132980-00 610.65 × 127.98(图纸 610.6 × 127.9, 偏差 0.01% / 0.06%);adx139058-00 1227.28 × 97.14(图纸 1228 × 97.8, 偏差 0.06% / 0.67%)。两件的图纸值分别指向 K≈0.40 与 K≈0.50,单一 K 不可能让 宽度同时进 0.5%——宽度按 K 因子区间包络验收,不硬凑某个 K。
此前 metamorphic T1 只比折弯数与板厚、不比毛坯,上面三个缺陷因此可以一直 藏着。新增 tests/test_blank_accuracy.py:13 件(含 2 件真实客户件)× 6 种姿态, 毛坯必须一模一样(等距展开是数学恒等式,容差 0.05 mm 纯为 STEP 往返噪声); 外加与图纸的精度门禁。
原先"各自四舍五入到固定位数再精确相等",真值一旦压在进位边界上就随机翻车 (45.6549 → 45.6 还是 45.7 取决于最后一位浮点噪声)。改为逐字段给物理容差 (板厚 0.02、折弯 0.3、孔 0.1、外形 0.2、毛坯 0.2 mm),"多大的差算差异"有了明确口径。
mcp ≥ 2 把 FastMCP 拆成了独立包,mcp.server.fastmcp 不再存在。 导入改为两条路径都试;MCP 测试改用 importorskip 跳过——可选集成的 上游变动 不该让核心库的流水线变红。tests/data/;hem_tight(Ri = t/2,折回臂与底板间隙恰好 = t 且投影完全重合)。UNFOLD_INCONSISTENT 误报(adx132980 报 1.045,实为 0.959)。 分母改为全部料片毛坯面积之和;welded_bracket(角支架 + 对接端板):面板图分成 4 块, 测试同时验证「逐块给毛坯」与「只按主料片当分母就会超 1」这两面。POST /recompute?path=…[&sub=N];multi_axis_unsupported 转人工);(v−w)·n_w ≈ −t, FaceRec.normal 是材料外向法向)。只比距离绝对值会在段差/Z 形件上配错—— 上板内皮与下板外皮的间距同样是 t,中间夹的却是空气。配错以后整块面板 以错误的皮进展开树,毛坯凭空少一块(AGS001938 少 29%);ordered_wire_points)。原先复用的 outer_wire_samples docstring 就写着"无序,用于跨度/投影度量"——当多边形用会 算错面积、把半平面裁剪切乱,还会在 DXF 里画出穿过零件的假线;UNFOLD_DISCONNECTED;UNFOLD_INCONSISTENT 自洽性检查不再只对单轴生效——多轴毛坯现在是真算的, 正是最该自查的地方;展开几何自己的告警此前根本没传到结果里,已接通。z_offset(段差 Z 形):段差高度必须是 2t 才能触发面板配对缺陷,专盯带符号判据;UNFOLD_APPROX);SHARP_WITH_RADIUSED, 强制人工复核,不静默计入也不静默丢弃;mixed_corner(R3 折弯 + 立在面板上的筋板)。blank_size_mm(长 ≥ 宽),方向语义 (展开方向 / 沿折弯轴)作为附注保留;CSV 增加 blank_long_mm/blank_short_mm。total_cut_mm 对齐;ShapeAnalysis_Surface 曲面投影——原占 P2 的绝大部分(5.8 ms/边 × 2040 边);spatial.py):共线轴线按"原点垂足"分桶,配对/板厚通道 B/ 孔同轴聚类由 O(n²) 降到近 O(n);/part?path=…&sub=N,结果同样走 LRU 缓存;data-done=1,秒开不打扰;.github/workflows/ci.yml):静态检查 + py3.10/3.12 测试矩阵 (pytest + metalmaster selftest)+ R 层棘轮;用蓄意回归的临时分支 实证三道关确实会失败,而非只看首跑绿灯;scripts/scoreboard.py 缺陷:原先检测到回归后仍覆写基线, 导致坏结果成为新基线、棘轮只拦得住一次;改为回归时保持基线不变。人能在浏览器里看:本地网页查看器 + 可转动的三维视图。
tessellate.py + viewer.py:STEP → 三角网格(按识别出的角色分组)+ CAD 原始边线 → 内联 WebGL 查看器(无 three.js 等外部库,CSP 友好);report --no-3d 可关闭以减小体积。metalmaster serve)/api/files、/api/inspect、/api/analyze), 人和机器看同一份事实;serve --host 0.0.0.0 时自动生成访问令牌(服务带上传功能,不应在 公网裸奔);令牌经 ?t= / X-Token 头 / Cookie 三选一校验, --token "" 可显式关闭;启动横幅打印可达地址与暴露提醒。sub → panel_group)、1 处空 f-string;182 用例全绿(新增 23:Web 协议层 15 含真实并发用例 + 三维网格/查看器 8)。
定位扩展:从「钣金报价分析引擎」到「STEP 认知服务(钣金深度专长)」。 agent 能了解任意零件/装配体,人能操作、查看、验证。
assembly.py:XCAF 装配结构——零件清单、STEP 产品名、实例数与位姿; XCAF 原型 + (体积,表面积) 几何哈希双重去重,重复件 ×N 作为报价用量信号 (真实件的产品名 "DYL-中-13" 现已读出并进入报告);facts.py:任意实体都成立的通用几何事实——拓扑计数、按曲面类型的 面积构成、圆柱特征清单(孔/凸台按材料侧区分)、质量属性与惯性回转半径, 据此给出零件类型判断(钣金/回转体/棱柱/薄壁/自由曲面)及其依据;NOT_SHEETLIKE 现在说明"它更像什么", 并照常输出 geometry 段。projection.py:HLR 消隐三视图 → SVG 线画,按最优姿态投影, 与报告的长×宽×高同一口径;htmlreport.py + metalmaster report:自包含单文件 HTML——三视图、 关键事实卡片、装配零件表、展开图(面板/条带/孔位)、折弯与孔明细、 风险与备选解释(概率条)、标记清单;明暗主题自适应,无外部依赖;metalmaster inspect:一屏认清一个陌生 STEP。analyze_parts():逐件分析装配中每种零件(实例共享结论,按种类算 一次)——类型判断、外形、估重、钣金口径;metalmaster inspect --parts、 MCP inspect_step 与 HTML 报告的装配表均已接入。inspect_step(认识任意 STEP,首选入口)与 html_report(把核对工作交给工程师),共 6 个工具。metalmaster batch --html-dir:逐件 HTML 报告 + 索引页,工程师批量复核;metalmaster selftest:不装 pytest 也能验证安装与算法——12 个判别夹具 的构造参数即 ground truth,逐件比对、退出码即结论;--html 另出报告 供肉眼核对;159 用例全绿(新增 15 个服务层测试:装配去重与逐件分析、非钣金事实、 三视图口径一致性、HTML 结构与内容、CLI/MCP 入口、自检命令)。
补齐设计文档 §7 干涉矩阵的逐行绿测(M4 完成定义中一直欠账的一项), 4 个新判别夹具当场抓出 2 个真问题:
SLANTED_HOLE (需二次装夹/折后加工的工艺信号);FLANGED_HOLE flag(§7-e 要求"flag 记录检出");unfold_dxf 工具:编排器一次调用拿到展开 DXF 文件与摘要(范围/面板/条带/孔数),多轴件降级不写文件;展开几何(develop.py):从毛坯口径到真实套料输入。
metalmaster unfold;工艺语义增强(全部影子模式,不改计数):
定位定稿:独立发展的钣金 STEP 报价分析引擎(基础组件)。
loop+cyl); 凸台/压凸根部内环经实体空腔判别排除。quote_inputs):外轮廓切割长度(容斥公式)+ 穿孔次数、 展开净面积与毛坯利用率(V/t 恒等式,兼作展开自洽性校验)、最长折弯线、 方向分布与翻面、最小翻边宽、二次工序清单。metalmaster[mcp] / metalmaster-mcp): analyze / quote_inputs / operations_estimate 三工具,Agent 编排器 零代码挂载;metalmaster.quoting 提供确定性工时模型(费率归宿主)。scripts/scoreboard.py,对→错即失败)。metalmaster_verdict.json schema + scripts/collect_verdicts.py 校准收集器。首版:P1–P8 流水线(STEP 载入修复、面分类、板厚双通道、Δr=t 折弯配对与 闸门验证、尖折弯补全、n_op/n_geo 双口径聚类、孔工艺分类、面板对齐外形、 置信度评分),CLI/Python API 双入口,4 夹具端到端测试。