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 index a825ee5..4187a49 100644 --- 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 @@ -279,3 +279,14 @@ - v112 从同一审计检查点选择右侧板候选后,结构、制造、B-rep和净化后的视觉阻塞均为 0;报告状态为 `candidate_visual_pass_requires_independent_acceptance`,没有自动宣告成功。人工独立查看最终正视和等轴视图后仍判定不合格:铰链虽从中缝移到左右外侧,但被画成横跨门板/侧板的长方体“贯穿件”,与家具暗铰链的铰杯、底座及孔位工艺不符。这证明几何合法不等于五金工艺语义正确。 - 本轮明确记录的失败路线:让模型只修改五金安装元数据;用通用长方体代替具体铰链家族;把视觉模型无证据的透视估算当尺寸修正;把运行时凭据缺失交给几何重试。后续不得重复这些做法,应增加有证据的五金家族/安装模板(例如铰杯直径与深度、门板杯孔、侧板底座孔距)并用原图或用户确认选择具体模板。 - v112 仍只是一组人工生成样例的隔离验证,未使用公司 BOM 参与推理、未修改生产服务。不同公司产品盲测、组装/零件外观独立验收、生产级五金孔位与冻结报价后总成本误差不超过 5%仍未完成,接口状态必须保持测试失败/继续优化。 + +## 2026-09-08 v113 五金家族模板与生产孔型门禁(候选,未上线) + +- v112 人工验收失败的直接原因不是铰链位置单轴偏差,而是表示层仍把暗铰链和旋钮当作任意长方体。移动长方体直到包围盒接触宿主只能让几何门变绿,不能得到铰杯、底座、连接臂、螺钉孔或可采购件数量;该路线正式停止,不再生成旧式 `compatible_placement_patches` 修复模板五金。 +- 新增版本化 `hardware_catalog.py`,首批模板为 35mm 暗铰链(TH52、系统32公开参考)与 M4 单孔旋钮。模型只能选择稳定 `hardware_family` / `hardware_template_id`,并为每个模板槽声明真实宿主、安装广面、模板规定的安装方式和嵌入深度、产品坐标孔型中心及原图证据。缺模板、模板家族不匹配、槽缺失/重复、旋钮整件贯穿、铰杯边距越界、底座不在37mm系统线均阻断。 +- 五金可视几何不再使用占位盒:暗铰链由铰杯圆柱、安装底座和两段正交连接臂组成,旋钮由表装圆柱组成;`1×1×1` 只保留为不可计价声明占位。模板挂载对宿主的合法相交由槽位门单独证明,未挂载的其他实体仍经过碰撞门。 +- 制造层按模板在宿主板上生成真实孔型。每只暗铰链恰好生成 1 个 35mm 铰杯孔、2 个 TH52 固定孔和 2 个系统32底座孔;每只旋钮恰好生成 1 个 4.5mm 螺钉通孔。采购五金每个物理实例只计一次,五金可视包围盒不会进入板材下料 BOM、接触推导或再次生成确认螺钉/木螺钉。 +- 公开模板保留 `public_reference_draft` 警告,未映射公司实际采购 SKU 前,制造结果状态只能是 `manufacturing_draft_unapproved_hardware`,禁止冒充公司已批准生产孔型。 +- 本地新增/改写 12 项五金专项验证并通过;最终完整回归 226 项全部通过(253.672 秒),Python 语法、`git diff --check` 与技能结构校验均通过。四个运行文件已同步到服务器 `/tmp/dalongxia-cad-v85-eI8HeF` 隔离目录,SHA-256 与本机完全一致且服务器静态编译通过。 +- 同步时服务器只读状态为 `active=0 waiting=0`,但另有 1 个得物1688持久任务执行中;因此本轮没有启动模型推理、没有覆盖生产文件、没有重启服务。真实 STEP/GLB 与 B-rep 五金模板构建仍须在服务器完全空闲后验证。 +- 真实多产品盲测、原图与组装/拆件独立验收、公司实际五金 SKU 批准,以及冻结估价后逐项和总成本误差不超过 5%仍未完成;候选不能部署或标记成功。 diff --git a/server_patch/ai_3d_model/ai-solid-wood-cost/hardware_catalog.py b/server_patch/ai_3d_model/ai-solid-wood-cost/hardware_catalog.py new file mode 100644 index 0000000..94f82e5 --- /dev/null +++ b/server_patch/ai_3d_model/ai-solid-wood-cost/hardware_catalog.py @@ -0,0 +1,121 @@ +"""Stable furniture-hardware templates used by the assembly compiler. + +The vision model may identify a hardware *family*, but it is not allowed to +invent production drilling dimensions. A manufacturing-capable graph must +reference one explicit template from this catalog (or a future company +catalog with the same contract). Public-reference templates remain visibly +marked as such until the company approves a purchasable fitting/SKU. +""" +from __future__ import annotations + +import copy + + +CATALOG_SCHEMA_VERSION = "dalongxia.hardware-catalog/1" + + +HARDWARE_TEMPLATES = { + "euro_hinge_35_th52_system32_v1": { + "family": "concealed_cup_hinge", + "display_name": "35mm暗铰链(TH52/系统32参考模板)", + "approval_status": "public_reference_draft", + "source_urls": [ + "https://shop.hettich.com/ro_EN/Hinges/Sensys-hinge-series/Hinges/" + "Sensys-110%C2%B0-hinge-without-integrated-silent-system-%28Sensys-8645%29%2C-" + "nickel-plated%2C-overlay%2C-Opening-angle-110%C2%B0%2C-TH-drilling-pattern-" + "52-x-5-5-mm%2C-for-screwing-on-%28-%29/p/9073638", + "https://mediacenter.grass.eu/Produktkataloge/Hinges/EN/179/", + ], + "hardware_axes": ["x", "z"], + "mounting_slots": { + "cup": { + "host_roles": ["door"], + "kind": "mortised", + "embed_depth_mm": 12.8, + "footprint_radius_mm": 17.6, + }, + "plate": { + "host_roles": ["side", "stile", "frame_member"], + "kind": "surface", + "embed_depth_mm": 0.0, + "footprint_half_width_mm": 16.0, + }, + }, + "manufacturing": { + "cup_diameter_mm": 35.0, + "cup_depth_mm": 12.8, + "cup_fixing_spacing_mm": 52.0, + "cup_fixing_diameter_mm": 5.5, + "cup_fixing_depth_mm": 12.0, + "cup_edge_distance_range_mm": [3.0, 7.0], + "plate_system_line_mm": 37.0, + "plate_hole_spacing_mm": 32.0, + "plate_hole_diameter_mm": 5.0, + "plate_hole_depth_mm": 12.0, + }, + "visual": { + "cup_diameter_mm": 35.0, + "cup_depth_mm": 12.8, + "plate_size_mm": [32.0, 2.0, 40.0], + "arm_thickness_mm": 8.0, + }, + "purchased_unit": "套", + }, + "knob_m4_surface_v1": { + "family": "knob", + "display_name": "单孔旋钮(M4参考模板)", + "approval_status": "public_reference_draft", + "source_urls": [], + "mounting_slots": { + "body": { + "host_roles": ["door", "drawer_front"], + "kind": "surface", + "embed_depth_mm": 0.0, + "footprint_radius_mm": 10.0, + }, + }, + "manufacturing": { + "through_hole_diameter_mm": 4.5, + }, + "visual": { + "body_diameter_mm": 20.0, + "body_projection_mm": 24.0, + }, + "purchased_unit": "个", + }, +} + + +def get_hardware_template(template_id: str) -> dict | None: + """Return an isolated template value so callers cannot mutate the catalog.""" + value = HARDWARE_TEMPLATES.get(str(template_id or "")) + return copy.deepcopy(value) if value else None + + +def supported_hardware_contract() -> list[dict]: + """Compact schema exposed to the graph-generating model.""" + contracts = [] + for template_id, template in sorted(HARDWARE_TEMPLATES.items()): + manufacturing = template.get("manufacturing") or {} + contracts.append({ + "hardware_template_id": template_id, + "hardware_family": template["family"], + "hardware_axes": list(template.get("hardware_axes") or []), + "mounting_slots": { + slot: { + "host_roles": list(rule["host_roles"]), + "kind": rule["kind"], + "embed_depth_mm": rule["embed_depth_mm"], + } + for slot, rule in sorted(template["mounting_slots"].items()) + }, + "placement_constraints": { + key: copy.deepcopy(manufacturing[key]) + for key in ( + "cup_edge_distance_range_mm", "plate_system_line_mm", + ) + if key in manufacturing + }, + "approval_status": template["approval_status"], + }) + return contracts 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 index 5e2b3f7..5f4cbc3 100644 --- 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 @@ -11,6 +11,8 @@ from __future__ import annotations import math from collections import Counter +from hardware_catalog import get_hardware_template + SCHEMA_VERSION = "dalongxia.manufacturing-feature-plan/1" @@ -219,9 +221,11 @@ def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) - "capture_allowances": [], } for key, row in instances.items() + if row.get("functional_role") != "hardware" } connection_plans = [] hardware = Counter() + hardware_template_warnings = [] hardware_names = { "confirmat": ("确认螺钉", "7×50mm"), "wood_screw": ("木螺钉", "4×40mm"), @@ -231,7 +235,8 @@ def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) - 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: + connection_id: str, hardware_id: str, hole_type: str, + source: str = "verified_geometric_contact") -> None: nonlocal sequence sequence += 1 axis_index = _axis_index(face) @@ -245,7 +250,7 @@ def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) - "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", + "hole_type": hole_type, "source": source, } errors = _validate_hole(instance, feature) feature["validation_status"] = "blocked" if errors else "valid" @@ -253,6 +258,110 @@ def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) - issue("hole_geometry", ";".join(errors), instance_id=instance["id"], feature_id=feature["feature_id"]) features[instance["id"]]["holes"].append(feature) + # Purchased fittings have their own selected drilling template. Count + # each physical fitting once, machine only its hosts, and never put the + # fitting envelope into the board/material cut list. + mountings_by_hardware: dict[str, dict[str, dict]] = {} + for row in compiled.get("mounting_audit") or []: + if row.get("template_geometry"): + mountings_by_hardware.setdefault(str(row["instance_id"]), {})[str(row["slot"])] = row + for hardware_id, slots in sorted(mountings_by_hardware.items()): + fitting = instances.get(hardware_id) + if not fitting: + issue("hardware_template_instance", "五金模板引用了不存在的实例", hardware_instance_id=hardware_id) + continue + template_id = str(fitting.get("hardware_template_id") or "") + template = get_hardware_template(template_id) + if not template or template.get("family") != fitting.get("hardware_family"): + issue( + "hardware_template_instance", "编译后的五金实例没有匹配模板", + hardware_instance_id=hardware_id, hardware_template_id=template_id, + ) + continue + if set(slots) != set(template["mounting_slots"]): + issue( + "hardware_template_mountings", "五金模板的宿主安装槽不完整", + hardware_instance_id=hardware_id, actual_slots=sorted(slots), + required_slots=sorted(template["mounting_slots"]), + ) + continue + if template.get("approval_status") != "company_approved": + hardware_template_warnings.append({ + "code": "hardware_template_not_company_approved", + "hardware_instance_id": hardware_id, + "hardware_template_id": template_id, + "approval_status": template.get("approval_status"), + "detail": "孔型来自公开参考模板;投产前必须映射到公司实际采购五金SKU并批准", + }) + hardware[template_id] += 1 + hardware_names[template_id] = (template["display_name"], template_id) + generated_before = sequence + if template["family"] == "concealed_cup_hinge": + manufacturing = template["manufacturing"] + hardware_axis = "xyz".index(str(fitting.get("hardware_axis") or "")) + cup = slots["cup"] + cup_host = instances[cup["host_instance_id"]] + cup_point = [float(value) for value in cup["mounting_center_mm"]] + cup_connection = f"mount:{hardware_id}:cup" + add_hole( + cup_host, point=cup_point, face=cup["face"], + diameter=float(manufacturing["cup_diameter_mm"]), + depth=float(manufacturing["cup_depth_mm"]), + connection_id=cup_connection, hardware_id=hardware_id, + hole_type="hinge_cup", source="selected_hardware_template", + ) + for sign in (-1.0, 1.0): + point = list(cup_point) + point[hardware_axis] += sign * float(manufacturing["cup_fixing_spacing_mm"]) / 2.0 + add_hole( + cup_host, point=point, face=cup["face"], + diameter=float(manufacturing["cup_fixing_diameter_mm"]), + depth=min(float(manufacturing["cup_fixing_depth_mm"]), float(cup_host["size_mm"][_axis_index(cup["face"])])), + connection_id=cup_connection, hardware_id=hardware_id, + hole_type="hinge_cup_fixing", source="selected_hardware_template", + ) + plate = slots["plate"] + plate_host = instances[plate["host_instance_id"]] + plate_point = [float(value) for value in plate["mounting_center_mm"]] + plate_connection = f"mount:{hardware_id}:plate" + for sign in (-1.0, 1.0): + point = list(plate_point) + point[hardware_axis] += sign * float(manufacturing["plate_hole_spacing_mm"]) / 2.0 + add_hole( + plate_host, point=point, face=plate["face"], + diameter=float(manufacturing["plate_hole_diameter_mm"]), + depth=min(float(manufacturing["plate_hole_depth_mm"]), float(plate_host["size_mm"][_axis_index(plate["face"])])), + connection_id=plate_connection, hardware_id=hardware_id, + hole_type="hinge_plate_fixing", source="selected_hardware_template", + ) + elif template["family"] == "knob": + body = slots["body"] + host = instances[body["host_instance_id"]] + normal_axis = _axis_index(body["face"]) + connection_id = f"mount:{hardware_id}:body" + add_hole( + host, point=[float(value) for value in body["mounting_center_mm"]], + face=body["face"], + diameter=float(template["manufacturing"]["through_hole_diameter_mm"]), + depth=float(host["size_mm"][normal_axis]), + connection_id=connection_id, hardware_id=hardware_id, + hole_type="handle_fastener_through", source="selected_hardware_template", + ) + generated_ids = [ + feature["feature_id"] + for rows in features.values() for feature in rows["holes"] + if feature.get("hardware_instance_id") == hardware_id + ] + connection_plans.append({ + "connection_id": f"hardware-template:{hardware_id}", + "method": "selected_hardware_template", + "hardware_instance_ids": [hardware_id], + "hardware_template_id": template_id, + "feature_ids": generated_ids, + "feature_count": sequence - generated_before, + "source": "verified_template_mounting_slots", + }) + 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: @@ -505,7 +614,11 @@ def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) - return { "schema_version": SCHEMA_VERSION, "ok": not issues, - "release_status": "manufacturing_draft" if not issues else "blocked", + "release_status": ( + "blocked" if issues else + "manufacturing_draft_unapproved_hardware" if hardware_template_warnings else + "manufacturing_draft" + ), "policy": active_policy, "issues": issues, "features_by_instance": features, @@ -513,11 +626,16 @@ def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) - "manufacturing_bom": list(manufacturing_bom_groups.values()), "connection_plans": connection_plans, "hardware_lines": hardware_lines, + "hardware_template_warnings": hardware_template_warnings, "cost_drivers": { "hole_operations": hole_count, "groove_operations": groove_count, "weld_length_m": round(weld_length / 1000.0, 4), "hardware_instances": sum(hardware.values()), + "hardware_template_instances": sum( + quantity for code, quantity in hardware.items() + if get_hardware_template(code) + ), }, "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 index b6a2d66..ae95f6f 100644 --- 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 @@ -22,6 +22,7 @@ from structure_graph import ( build_source_topology_contract, compile_graph, normalize_graph_patch_payload, ) from manufacturing_oracle import plan_manufacturing_features +from hardware_catalog import supported_hardware_contract ROLE_FILENAME_ALIASES = { @@ -153,6 +154,12 @@ def enrich_hardware_feedback_with_verified_placements( while leaving the hardware body hundreds of millimetres away. """ result = copy.deepcopy(feedback) + parts = {str(row.get("id") or ""): row for row in graph.get("parts") or []} + template_instances = { + str(row.get("id") or "") + for row in graph.get("instances") or [] + if (parts.get(str(row.get("part_id") or "")) or {}).get("hardware_template_id") + } solved = {str(row.get("id")): row for row in compiled.get("instances") or []} current_issue_count = len(compiled.get("issues") or []) envelope = list(compiled.get("occupied_envelope_mm") or [0, 0, 0]) @@ -163,6 +170,18 @@ def enrich_hardware_feedback_with_verified_placements( ): continue instance_id = str(item.get("instance_id") or "") + if instance_id in template_instances: + # Template geometry is driven by explicit mounting centres and + # fixed slot rules, never by moving an arbitrary placeholder box + # until its AABB happens to touch a board. + item.pop("compatible_placement_patches", None) + item["correction"] = ( + "回看原图修正该模板五金的slot、host_instance_id、face与mounting_center_mm;" + "kind和embed_depth_mm必须逐字使用模板合同。禁止移动1×1×1占位placement、" + "禁止把整个五金改成through来制造接触。" + ) + item["repair_contract"] = "template_mounting_centers_only" + continue host_id = str(item.get("host_instance_id") or "") hardware, host = solved.get(instance_id), solved.get(host_id) path = str(item.get("path") or "") @@ -842,7 +861,7 @@ GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具 侧板是否断开需要局部接缝证据,颜色变化不等于断开,层板边缘不等于侧板接缝。 多张图存在矛盾则在uncertainties中记录;未看见不能当作没有。 严格JSON结构: - {"schema_version":"furniture-assembly-graph/1", + {"schema_version":"furniture-assembly-graph/2", "placement_solver":"anchored_axes_v1", "orientation_solver":"role_axis_templates_v1", "contact_derivation":"aabb_within_assembly_v1", @@ -850,8 +869,8 @@ GRAPH_PROMPT = """根据全部原始图片构建可编辑、可验证的家具 "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":["实际占用宽","实际占用深","实际占用高"], "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":"panel_part_id","assembly_id":"明确所属总成ID","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}}},{"id":"五金物理实例ID","part_id":"hardware_part_id","assembly_id":"明确所属总成ID","placement":{"x":{"mode":"absolute","value_mm":0},"y":{"mode":"absolute","value_mm":0},"z":{"mode":"absolute","value_mm":0}},"mountings":[{"host_instance_id":"实际安装板件ID","kind":"surface","face":"y-","embed_depth_mm":0,"evidence_ids":["e1"]}]}], + "parts":[{"id":"part_id","name":"中文部件名","shape":"box","functional_role":"side","thickness_axis":"x","size_mm":["参数或算式","参数或算式","参数或算式"],"observed_quantity":1,"evidence_ids":["e1"]},{"id":"hinge_part","name":"暗铰链","shape":"box","functional_role":"hardware","thickness_axis":null,"hardware_family":"concealed_cup_hinge","hardware_template_id":"euro_hinge_35_th52_system32_v1","size_mm":[1,1,1],"observed_quantity":1,"evidence_ids":["e1"]}], + "instances":[{"id":"板件物理实例ID","part_id":"panel_part_id","assembly_id":"明确所属总成ID","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}}},{"id":"五金物理实例ID","part_id":"hinge_part","assembly_id":"门总成ID","hardware_axis":"z","placement":{"x":{"mode":"absolute","value_mm":0},"y":{"mode":"absolute","value_mm":0},"z":{"mode":"absolute","value_mm":0}},"mountings":[{"slot":"cup","host_instance_id":"门板实例ID","kind":"mortised","face":"y+","embed_depth_mm":12.8,"mounting_center_mm":[22,20,150],"evidence_ids":["e1"]},{"slot":"plate","host_instance_id":"侧板实例ID","kind":"surface","face":"x+","embed_depth_mm":0,"mounting_center_mm":[20,57,150],"evidence_ids":["e1"]}]}], "connections":[], "assembly_connections":[{"id":"drawer_slide","kind":"sliding","a":"drawer_1","b":"carcass","axis":"y","travel_limits_mm":[0,250],"evidence_ids":["e1"]}], "uncertainties":[]} @@ -878,9 +897,9 @@ assembly.role只允许carcass/drawer/door/module/frame/hardware_group/other; 抽屉的前/后/左/右帮板和底板属于同一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数量只等于真实实例数,不额外产生总成板。 -每个functional_role=hardware的物理实例必须声明mountings,指向真实安装的非五金宿主实例。把手通常一个宿主,铰链可有门板和柜体两个宿主;不得只靠中文名称表示安装关系。 -mountings.kind只允许surface/mortised/through;face是宿主朝外安装面x-/x+/y-/y+/z-/z+;embed_depth_mm是实际嵌入宿主的深度,surface必须为0。安装件在该面投影上必须与宿主有正面积重叠,法向必须接触或严格符合嵌入深度。五金可经过验证后外凸于结构总成边界,但不能藉此掩盖把手装到错误门板、悬空或非宣称宿主的穿插。 -总成bounds_mm只由门板、抽屉板、柜体板等结构实体的紧包围盒确定,不应把把手、铰链的外凸纳入边界。五金与宿主的正体积交只有在mortised/through关系且嵌入深度与解算几何一致时才允许。 +每个functional_role=hardware的part必须声明hardware_family和hardware_template_id;模型只能选系统提供的模板,禁止按中文名称或任意长方体自创孔径、孔距与嵌入深度。part的size_mm仅是1×1×1占位,最终外形由模板编译。 +每个五金实例必须为模板的每个安装槽声明mountings。每条含slot、真实宿主实例、模板规定的kind、宿主广面face、模板规定的embed_depth_mm、产品坐标mounting_center_mm和原图证据。暗铰链还必须写hardware_axis;cup槽指向门板,plate槽指向柜体侧板/框条。旋钮body必须是门板外表面的surface安装,穿门螺钉孔由模板生成,禁止把整个旋钮写成through。 +mounting_center_mm是孔型中心,必须在宿主安装平面上且完整孔型不能越界。35mm暗铰杯的边距和底座系统线由模板校验;不得修改模板孔径、孔距、嵌入深度或用through绕过。总成bounds_mm只由结构实体紧包围盒确定,不纳入五金外凸。 所有运动连接必须引用真实实例和真实总成,不能用大公差掩盖错位。 只画现有图像支持的结构;不要根据常见柜子想象层板数量。未标注内部尺寸先由AI按多图比例估算并标记estimated,生成评审草案; 只有原图完全不可见且对制造/成本高度敏感、经过局部复读与CAD反向复核仍不能判定的字段,才进入用户确认候选。 @@ -923,6 +942,11 @@ def diagnostic_mounting_clearance_cuts(compiled: dict, *, clearance_mm: float = audit = [] pad = max(0.0, float(clearance_mm)) / 2.0 for mounting in compiled.get("mounting_audit") or []: + # Template-backed fittings receive real cup/fastener holes from the + # manufacturing planner. Subtracting their composite AABB would cut + # away unrelated door/side material and make a false clean B-rep. + if mounting.get("template_geometry"): + continue if not mounting.get("allows_host_interference"): continue hardware_id = str(mounting.get("instance_id") or "") @@ -968,8 +992,38 @@ def cad_source(compiled, manufacturing_plan=None): 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 {} + hardware_geometry = compiled.get("hardware_geometry_by_instance") or {} mounting_clearances = diagnostic_mounting_clearance_cuts(compiled)["cuts_by_host"] for index, inst in enumerate(compiled["instances"]): + template_geometry = hardware_geometry.get(str(inst["id"])) + if template_geometry: + primitive_names = [] + for primitive_index, primitive in enumerate(template_geometry["primitives"], 1): + variable = f"hardware_{index}_{primitive_index}" + primitive_names.append(variable) + center = primitive["center_mm"] + if primitive["kind"] == "box": + size = primitive["size_mm"] + lines.append( + f" {variable} = Box({size[0]!r}, {size[1]!r}, {size[2]!r}, " + f"align=(Align.CENTER, Align.CENTER, Align.CENTER)).moved(" + f"Pos({center[0]!r}, {center[1]!r}, {center[2]!r}))" + ) + else: + axis = primitive["axis"] + rotate = ".rotate(Axis.Y, 90)" if axis == "x" else ".rotate(Axis.X, 90)" if axis == "y" else "" + lines.append( + f" {variable} = Cylinder({float(primitive['diameter_mm']) / 2!r}, " + f"{float(primitive['length_mm'])!r}, align=(Align.CENTER, Align.CENTER, Align.CENTER))" + f"{rotate}.moved(Pos({center[0]!r}, {center[1]!r}, {center[2]!r}))" + ) + lines.extend([ + f" part = Compound(children=[{', '.join(primitive_names)}])", + f" part.label = {str(inst['id'])!r}", + f" part.color = Color{colors[index % len(colors)]!r}", + " parts.append(part)", + ]) + continue 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"] @@ -1465,7 +1519,12 @@ def main(): round_base_graph = graph round_base_feedback = list(feedback) round_base_compile_issue_count = accepted_compile_issue_count - prompt = GRAPH_PROMPT + evidence_context + prompt = ( + GRAPH_PROMPT + + "\n本次允许使用的五金模板合同:" + + json.dumps(supported_hardware_contract(), ensure_ascii=False, separators=(",", ":")) + + evidence_context + ) if graph: prompt = ( "你在修正一份已存在的参数化家具结构图谱。必须回看本轮所有原图,并只返回局部JSON修改,不能返回整份图谱。" @@ -1481,11 +1540,13 @@ def main(): "图谱固定使用orientation_solver=role_axis_templates_v1;每个板件必须保留functional_role,size_mm直接表示产品X/Y/Z,thickness_axis也是产品轴。" "严禁新增或修改axis_order;若角色朝向冲突,必须修正part的size_mm与thickness_axis,不能对instance二次旋转。" "编译器会用这些字段区分面贴面、边贴面与边贴边,不允许用名称代替接口语义。" - "每个hardware实例必须声明mountings的host_instance_id/kind/face/embed_depth_mm/evidence_ids;" - "surface安装嵌入深度为0,mortised/through必须与实际穿入宿主的几何深度一致。" - "铰链等跨越两个结构件的五金可声明两个mountings;修复五金时不得改大嵌入深度来掩盖错位。" - "hardware_mounting_geometry反馈中若有compatible_mounting_values,必须优先逐字采用其中kind/face/embed_depth_mm;" - "该候选由解算几何生成,不得继续猜测其他轴面。候选为空时才修正宿主或placement。" + "每个hardware part必须声明本轮允许合同中的hardware_family和hardware_template_id," + "每个hardware实例必须补齐该模板全部mounting slots;每个slot包含slot、host_instance_id、" + "kind、face、embed_depth_mm、mounting_center_mm和evidence_ids。kind、embed_depth_mm、" + "宿主角色必须逐字服从模板合同,35mm铰链还要声明允许的hardware_axis,并满足杯孔边距和底座系统线。" + "五金part的1×1×1与实例placement只是不可计价占位,禁止移动占位盒制造接触,禁止把旋钮或整只铰链写成through。" + "hardware_mounting_geometry只允许回看原图修正slot宿主、广面face与mounting_center_mm;" + "不得修改模板孔径、孔距、嵌入深度或把另一处无关板件挪来配合五金。" "solid_interference反馈中若有compatible_separation_candidates,必须回看原图后优先选择" "最小必要位移且符合真实装配方向的候选;位移量相同时再选" "remaining_collision_count更少的候选,逐字采用其patches;" @@ -1499,7 +1560,10 @@ def main(): "合法示例:{\"patches\":[{\"op\":\"replace\",\"path\":\"/instances/door_left_inst/placement/x\"," "\"value\":{\"mode\":\"absolute\",\"value_mm\":20}}]}。" "路径可修改observations、parameters、envelope_mm、reference_bounds_mm、dimension_scope_overrides、assemblies、parts、instances、connections、assembly_connections、uncertainties、placement_solver、orientation_solver、contact_derivation;" - "数组追加使用/-。修改后仍需符合furniture-assembly-graph/1。\n上一版图谱:" + "数组追加使用/-。修改后必须符合furniture-assembly-graph/2。" + "\n本轮允许的完整五金模板合同:" + + json.dumps(supported_hardware_contract(), ensure_ascii=False, separators=(",", ":")) + + "\n上一版图谱:" + json.dumps(graph, ensure_ascii=False) + evidence_context + "\n编译器及原图视觉复核的结构化反馈:" + json.dumps(feedback, ensure_ascii=False) 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 index 28f3b81..27818e5 100644 --- 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 @@ -15,8 +15,9 @@ import re import copy from collections import Counter +from hardware_catalog import HARDWARE_TEMPLATES, get_hardware_template -SCHEMA_VERSION = "furniture-assembly-graph/1" +SCHEMA_VERSION = "furniture-assembly-graph/2" # New graphs use one product coordinate system end-to-end: X=width, # Y=depth, Z=height. These templates are role semantics, not Chinese-name @@ -688,6 +689,12 @@ def _derive_aabb_contacts(instances: dict, *, tolerance_mm: float = 0.6) -> list rows = list(instances.values()) for left_index, a in enumerate(rows): for b in rows[left_index + 1:]: + # Purchased fittings are connected through validated template + # mountings. Treating their visual envelope as a board contact + # would invent confirmats/wood screws and charge the same fitting + # a second time. + if "hardware" in {a.get("functional_role"), b.get("functional_role")}: + continue if a.get("assembly_id") != b.get("assembly_id"): continue contact = _aabb_contact(a, b, tolerance_mm=tolerance_mm) @@ -720,279 +727,360 @@ def _derive_aabb_contacts(instances: dict, *, tolerance_mm: float = 0.6) -> list return contacts +def _primitive_bounds(primitive: dict) -> tuple[list[float], list[float]]: + center = [float(value) for value in primitive["center_mm"]] + if primitive["kind"] == "box": + half = [float(value) / 2.0 for value in primitive["size_mm"]] + elif primitive["kind"] == "cylinder": + axis = "xyz".index(primitive["axis"]) + half = [float(primitive["diameter_mm"]) / 2.0] * 3 + half[axis] = float(primitive["length_mm"]) / 2.0 + else: + raise GraphError("五金模板含不支持的几何基元") + return ( + [center[index] - half[index] for index in range(3)], + [center[index] + half[index] for index in range(3)], + ) + + +def _build_hardware_template_geometry( + hardware: dict, template: dict, mounting_rows: dict[str, dict] +) -> dict: + """Build a small auditable fitting model from verified mounting points.""" + primitives: list[dict] = [] + family = template["family"] + if family == "concealed_cup_hinge": + cup, plate = mounting_rows["cup"], mounting_rows["plate"] + visual = template["visual"] + cup_center = list(cup["mounting_center_mm"]) + cup_axis = "xyz".index(cup["face"][0]) + cup_sign = 1.0 if cup["face"].endswith("-") else -1.0 + cup_center[cup_axis] += cup_sign * float(visual["cup_depth_mm"]) / 2.0 + primitives.append({ + "kind": "cylinder", "role": "hinge_cup", "axis": cup["face"][0], + "center_mm": cup_center, "diameter_mm": float(visual["cup_diameter_mm"]), + "length_mm": float(visual["cup_depth_mm"]), + }) + plate_center = list(plate["mounting_center_mm"]) + plate_axis = "xyz".index(plate["face"][0]) + hardware_axis = "xyz".index(hardware["hardware_axis"]) + other_axis = next(axis for axis in range(3) if axis not in {plate_axis, hardware_axis}) + plate_size = [0.0, 0.0, 0.0] + plate_size[plate_axis] = float(visual["plate_size_mm"][1]) + plate_size[hardware_axis] = float(visual["plate_size_mm"][2]) + plate_size[other_axis] = float(visual["plate_size_mm"][0]) + plate_sign = -1.0 if plate["face"].endswith("-") else 1.0 + plate_center[plate_axis] += plate_sign * plate_size[plate_axis] / 2.0 + primitives.append({ + "kind": "box", "role": "hinge_mounting_plate", + "center_mm": plate_center, "size_mm": plate_size, + }) + # A single axis-aligned bounding box between the cup and plate looks + # like a solid bar crossing both boards. Represent the crank arm as + # two narrow orthogonal segments. This is still a diagnostic fitting + # envelope, but it preserves the real L-shaped connection topology. + arm_thickness = float(visual["arm_thickness_mm"]) + cup_point = [float(value) for value in cup["mounting_center_mm"]] + plate_point = [float(value) for value in plate_center] + bend = list(cup_point) + bend[plate_axis] = plate_point[plate_axis] + for role, start, end in ( + ("hinge_arm_from_cup", cup_point, bend), + ("hinge_arm_to_plate", bend, plate_point), + ): + size = [arm_thickness, arm_thickness, arm_thickness] + center = [(start[axis] + end[axis]) / 2.0 for axis in range(3)] + for axis in range(3): + size[axis] = max(arm_thickness, abs(end[axis] - start[axis]) + arm_thickness) + size[hardware_axis] = arm_thickness + primitives.append({"kind": "box", "role": role, "center_mm": center, "size_mm": size}) + elif family == "knob": + body = mounting_rows["body"] + visual = template["visual"] + center = list(body["mounting_center_mm"]) + normal_axis = "xyz".index(body["face"][0]) + outward = -1.0 if body["face"].endswith("-") else 1.0 + center[normal_axis] += outward * float(visual["body_projection_mm"]) / 2.0 + primitives.append({ + "kind": "cylinder", "role": "knob_body", "axis": body["face"][0], + "center_mm": center, "diameter_mm": float(visual["body_diameter_mm"]), + "length_mm": float(visual["body_projection_mm"]), + }) + else: + raise GraphError("五金家族尚无可执行几何模板") + lows, highs = zip(*(_primitive_bounds(row) for row in primitives)) + low = [min(row[axis] for row in lows) for axis in range(3)] + high = [max(row[axis] for row in highs) for axis in range(3)] + return { + "instance_id": hardware["id"], + "hardware_family": family, + "hardware_template_id": hardware["hardware_template_id"], + "approval_status": template["approval_status"], + "primitives": primitives, + "position_mm": low, + "size_mm": [high[axis] - low[axis] for axis in range(3)], + "source": "verified_hardware_template_and_mounting_points", + } + + def _validate_hardware_mountings( instances: dict, *, observations: dict, resolver: "ParameterResolver", tolerance_mm: float = 0.6, -) -> tuple[list[dict], list[dict]]: - """Validate explicit hardware-to-host installation geometry. +) -> tuple[list[dict], list[dict], dict[str, dict]]: + """Validate fitting family, template, host slots and drilling centres. - Hardware is not an ordinary structural box: a knob may protrude beyond a - door's structural envelope and a mortised hinge may occupy a bounded part - of its host. Neither case is a licence to ignore collisions. The graph - must name every host, mounting face and embed depth; this function then - proves contact from solved geometry. It deliberately uses stable roles - and IDs, never product names or SKU templates. + A purchased fitting is no longer accepted as an arbitrary AABB. Its + template fixes the required host roles and body/machining relationship; + the graph provides evidence-backed mounting centres, and geometry proves + that every footprint fits the selected host face. """ faces = {"x-", "x+", "y-", "y+", "z-", "z+"} - allowed_kinds = {"surface", "mortised", "through"} audits: list[dict] = [] issues: list[dict] = [] + geometry: dict[str, dict] = {} def mounting_issue(code: str, instance_id: str, index: int | None, detail: str, **context): path = f"/instances/{instance_id}/mountings" if index is not None: path += f"/{index}" issues.append({ - "code": code, - "path": path, - "detail": detail, - "repair_owner": "ai", - "instance_id": instance_id, - **context, + "code": code, "path": path, "detail": detail, "repair_owner": "ai", + "instance_id": instance_id, **context, }) for instance_id, hardware in instances.items(): raw_mountings = hardware.get("mountings") - is_hardware = hardware.get("functional_role") == "hardware" - if not is_hardware: + if hardware.get("functional_role") != "hardware": if raw_mountings not in (None, []): mounting_issue( "mounting_on_non_hardware", instance_id, None, "只有functional_role=hardware的实例可以声明mountings", ) continue + start_issue_count = len(issues) + family = str(hardware.get("hardware_family") or "") + template_id = str(hardware.get("hardware_template_id") or "") + template = get_hardware_template(template_id) + if not family or not template_id: + mounting_issue( + "hardware_template_missing", instance_id, None, + "五金必须声明稳定hardware_family和hardware_template_id;禁止用中文名称或任意盒子代替生产模板", + supported_templates=sorted(HARDWARE_TEMPLATES), + ) + continue + if not template or template.get("family") != family: + mounting_issue( + "hardware_template_unknown", instance_id, None, + "五金模板不存在或与hardware_family不匹配", + hardware_family=family, hardware_template_id=template_id, + supported_templates=sorted(HARDWARE_TEMPLATES), + ) + continue + slot_rules = template["mounting_slots"] + hardware_axis = str(hardware.get("hardware_axis") or "") + if template.get("hardware_axes") and hardware_axis not in template["hardware_axes"]: + mounting_issue( + "hardware_axis", instance_id, None, + "该五金模板必须声明允许的转轴方向", + actual=hardware_axis, allowed=template["hardware_axes"], + ) + continue if not isinstance(raw_mountings, list) or not raw_mountings: mounting_issue( "hardware_mounting_missing", instance_id, None, - "五金实例必须声明至少一个可由几何验证的宿主板件安装关系", - legal_shape={ - "host_instance_id": "稳定实例ID", "kind": "surface|mortised|through", - "face": "x-|x+|y-|y+|z-|z+", "embed_depth_mm": 0, - "evidence_ids": ["真实图像证据ID"], - }, + "五金实例必须为模板的每个安装槽声明宿主、加工面和mounting_center_mm", + required_slots=sorted(slot_rules), ) continue if len(raw_mountings) > 4: - mounting_issue("hardware_mounting_count", instance_id, None, "单个五金实例最多声明4个安装宿主") + mounting_issue("hardware_mounting_count", instance_id, None, "单个五金实例最多声明4个安装槽") continue - seen_hosts = set() + seen_slots: set[str] = set() + seen_hosts: set[str] = set() + instance_audits: dict[str, dict] = {} for index, mounting in enumerate(raw_mountings): if not isinstance(mounting, dict): mounting_issue("hardware_mounting_shape", instance_id, index, "安装关系必须是JSON对象") continue - host_id = mounting.get("host_instance_id") + slot = str(mounting.get("slot") or "") + rule = slot_rules.get(slot) + if not rule or slot in seen_slots: + mounting_issue( + "hardware_mounting_slot", instance_id, index, + "安装槽缺失、重复或不属于所选五金模板", + actual_slot=slot, required_slots=sorted(slot_rules), + ) + continue + seen_slots.add(slot) + host_id = str(mounting.get("host_instance_id") or "") host = instances.get(host_id) if not host or host_id == instance_id or host.get("functional_role") == "hardware": mounting_issue( "hardware_mounting_host", instance_id, index, - "安装宿主必须是存在且非五金的物理实例", - host_instance_id=host_id, + "安装宿主必须是存在且非五金的物理实例", host_instance_id=host_id, ) continue if host_id in seen_hosts: mounting_issue( "hardware_mounting_duplicate_host", instance_id, index, - "同一五金不能重复声明同一安装宿主", - host_instance_id=host_id, + "同一模板的不同安装槽不能重复指向同一宿主", host_instance_id=host_id, ) continue seen_hosts.add(host_id) + if host.get("functional_role") not in set(rule["host_roles"]): + mounting_issue( + "hardware_mounting_host_role", instance_id, index, + "安装宿主角色与五金模板槽不兼容", + slot=slot, actual_role=host.get("functional_role"), allowed_roles=rule["host_roles"], + ) + continue kind, face = mounting.get("kind"), mounting.get("face") - if kind not in allowed_kinds or face not in faces: + if kind != rule["kind"] or face not in faces: mounting_issue( "hardware_mounting_kind", instance_id, index, - "安装方式只支持surface/mortised/through且必须声明宿主安装面", - actual_kind=kind, actual_face=face, + "安装方式必须由模板槽决定,不能用through或增大嵌入深度绕过几何", + slot=slot, actual_kind=kind, required_kind=rule["kind"], actual_face=face, ) continue refs = mounting.get("evidence_ids") or [] if not refs or any(ref not in observations for ref in refs): mounting_issue( "hardware_mounting_evidence", instance_id, index, - "五金安装关系必须引用本轮真实图像或用户证据", - host_instance_id=host_id, + "五金家族、宿主和位置必须引用本轮真实图像或用户证据", host_instance_id=host_id, ) continue try: declared_embed = resolver.length(mounting.get("embed_depth_mm", 0)) + center = [resolver.length(value) for value in mounting["mounting_center_mm"]] + if len(center) != 3: + raise GraphError("mounting_center_mm必须是产品X/Y/Z三个坐标") except (GraphError, TypeError, KeyError) as exc: mounting_issue( - "hardware_mounting_depth", instance_id, index, - f"安装嵌入深度无效: {exc}", host_instance_id=host_id, + "hardware_mounting_center", instance_id, index, + f"安装中心无效: {exc}", host_instance_id=host_id, slot=slot, ) continue - axis = "xyz".index(face[0]) - host_low = host["position_mm"][axis] - host_high = host_low + host["size_mm"][axis] - hardware_low = hardware["position_mm"][axis] - hardware_high = hardware_low + hardware["size_mm"][axis] - host_plane = host_low if face[1] == "-" else host_high - normal_overlap = max( - 0.0, min(host_high, hardware_high) - max(host_low, hardware_low) - ) - if face[1] == "+": - actual_embed = normal_overlap - gap = ( - hardware_low - host_plane if hardware_low > host_plane - else host_low - hardware_high if hardware_high < host_low - else max(0.0, host_plane - hardware_high) - ) - else: - actual_embed = normal_overlap - gap = ( - host_plane - hardware_high if hardware_high < host_plane - else hardware_low - host_high if hardware_low > host_high - else max(0.0, hardware_low - host_plane) - ) - tangent_axes = [dimension for dimension in range(3) if dimension != axis] - tangent_overlaps = [ - min( - host["position_mm"][dimension] + host["size_mm"][dimension], - hardware["position_mm"][dimension] + hardware["size_mm"][dimension], - ) - max(host["position_mm"][dimension], hardware["position_mm"][dimension]) - for dimension in tangent_axes - ] - host_thickness = host["size_mm"][axis] - depth_tolerance = max(tolerance_mm, declared_embed * 0.1) - geometric_errors = [] - host_thickness_axis = host.get("thickness_axis") - if host_thickness_axis in {"x", "y", "z"} and face[0] != host_thickness_axis: - geometric_errors.append( - f"安装面{face}不是宿主板件厚度轴{host_thickness_axis}的广面" - ) - if min(tangent_overlaps) <= tolerance_mm: - geometric_errors.append("在安装面上与宿主没有正面积重叠") - if gap > tolerance_mm: - geometric_errors.append(f"与宿主安装面间隙{gap:.3f}mm超差") - if declared_embed < 0 or declared_embed > host_thickness + tolerance_mm: - geometric_errors.append("声明嵌入深度超出宿主法向厚度") - if kind == "surface": - if declared_embed > tolerance_mm: - geometric_errors.append("surface安装的embed_depth_mm必须为0") - if actual_embed > tolerance_mm: - geometric_errors.append(f"表面安装却实际穿入宿主{actual_embed:.3f}mm") - elif kind == "mortised": - if declared_embed <= tolerance_mm: - geometric_errors.append("嵌入式安装必须声明大于公差的嵌入深度") - elif abs(actual_embed - declared_embed) > depth_tolerance: - geometric_errors.append( - f"实际嵌入{actual_embed:.3f}mm与声明{declared_embed:.3f}mm不符" - ) - elif kind == "through": - if abs(declared_embed - host_thickness) > tolerance_mm: - geometric_errors.append( - f"贯穿安装应声明宿主全厚{host_thickness:.3f}mm而不是{declared_embed:.3f}mm" - ) - if actual_embed < host_thickness - tolerance_mm: - geometric_errors.append( - f"贯穿安装只穿入{actual_embed:.3f}mm,未穿过宿主{host_thickness:.3f}mm厚度" - ) - if geometric_errors: - compatible_mounting_values = [] - for candidate_face in sorted(faces): - candidate_axis = "xyz".index(candidate_face[0]) - if ( - host_thickness_axis in {"x", "y", "z"} - and candidate_face[0] != host_thickness_axis - ): - continue - candidate_host_low = host["position_mm"][candidate_axis] - candidate_host_high = candidate_host_low + host["size_mm"][candidate_axis] - candidate_hardware_low = hardware["position_mm"][candidate_axis] - candidate_hardware_high = candidate_hardware_low + hardware["size_mm"][candidate_axis] - candidate_plane = ( - candidate_host_low if candidate_face[1] == "-" else candidate_host_high - ) - candidate_normal_overlap = max( - 0.0, - min(candidate_host_high, candidate_hardware_high) - - max(candidate_host_low, candidate_hardware_low), - ) - if candidate_face[1] == "+": - candidate_embed = candidate_normal_overlap - candidate_gap = ( - candidate_hardware_low - candidate_plane - if candidate_hardware_low > candidate_plane - else candidate_host_low - candidate_hardware_high - if candidate_hardware_high < candidate_host_low - else max(0.0, candidate_plane - candidate_hardware_high) - ) - outward_projection = max(0.0, candidate_hardware_high - candidate_plane) - else: - candidate_embed = candidate_normal_overlap - candidate_gap = ( - candidate_plane - candidate_hardware_high - if candidate_hardware_high < candidate_plane - else candidate_hardware_low - candidate_host_high - if candidate_hardware_low > candidate_host_high - else max(0.0, candidate_hardware_low - candidate_plane) - ) - outward_projection = max(0.0, candidate_plane - candidate_hardware_low) - candidate_tangent_axes = [ - dimension for dimension in range(3) if dimension != candidate_axis - ] - candidate_overlaps = [ - min( - host["position_mm"][dimension] + host["size_mm"][dimension], - hardware["position_mm"][dimension] + hardware["size_mm"][dimension], - ) - max( - host["position_mm"][dimension], hardware["position_mm"][dimension] - ) - for dimension in candidate_tangent_axes - ] - if candidate_gap > tolerance_mm or min(candidate_overlaps) <= tolerance_mm: - continue - candidate_host_thickness = host["size_mm"][candidate_axis] - if candidate_embed <= tolerance_mm: - candidate_kind, candidate_depth = "surface", 0.0 - elif candidate_embed < candidate_host_thickness - tolerance_mm: - candidate_kind, candidate_depth = "mortised", candidate_embed - else: - candidate_kind, candidate_depth = "through", candidate_host_thickness - compatible_mounting_values.append({ - "kind": candidate_kind, - "face": candidate_face, - "embed_depth_mm": round(candidate_depth, 6), - "outward_projection_mm": round(outward_projection, 6), - "tangent_overlap_mm": [round(value, 6) for value in candidate_overlaps], - }) - compatible_mounting_values.sort( - key=lambda row: ( - row["outward_projection_mm"], min(row["tangent_overlap_mm"]) - ), - reverse=True, - ) + required_embed = float(rule["embed_depth_mm"]) + if abs(declared_embed - required_embed) > tolerance_mm: mounting_issue( - "hardware_mounting_geometry", instance_id, index, - ";".join(geometric_errors), host_instance_id=host_id, - kind=kind, face=face, declared_embed_depth_mm=declared_embed, - actual_embed_depth_mm=actual_embed, gap_mm=gap, - tangent_overlap_mm=tangent_overlaps, - compatible_mounting_values=compatible_mounting_values[:3], - correction=( - "先根据compatible_mounting_values修正kind/face/embed_depth_mm;若无候选," - "说明五金与该宿主的位置本身不可成立,再修改实例placement或宿主;" - "不得通过改大嵌入深度掩盖错位。" - ), + "hardware_mounting_depth", instance_id, index, + "安装嵌入深度必须来自模板,不能由模型自由猜测", + slot=slot, actual=declared_embed, required=required_embed, ) continue - audits.append({ - "instance_id": instance_id, - "host_instance_id": host_id, - "kind": kind, - "face": face, + normal_axis = "xyz".index(face[0]) + host_thickness_axis = host.get("thickness_axis") + host_plane = float(host["position_mm"][normal_axis]) + ( + float(host["size_mm"][normal_axis]) if face.endswith("+") else 0.0 + ) + errors = [] + if host_thickness_axis in {"x", "y", "z"} and face[0] != host_thickness_axis: + errors.append(f"安装面{face}不是宿主厚度轴{host_thickness_axis}的广面") + gap = abs(float(center[normal_axis]) - host_plane) + if gap > tolerance_mm: + errors.append(f"安装中心不在宿主安装面上,法向偏差{gap:.3f}mm") + tangent_axes = [axis for axis in range(3) if axis != normal_axis] + margins = {axis: float(rule.get("footprint_radius_mm") or rule.get("footprint_half_width_mm") or 0.0) for axis in tangent_axes} + if family == "concealed_cup_hinge" and slot == "cup": + fix_margin = ( + float(template["manufacturing"]["cup_fixing_spacing_mm"]) / 2.0 + + float(template["manufacturing"]["cup_fixing_diameter_mm"]) / 2.0 + ) + margins["xyz".index(hardware_axis)] = max(margins["xyz".index(hardware_axis)], fix_margin) + if family == "concealed_cup_hinge" and slot == "plate": + plate_margin = ( + float(template["manufacturing"]["plate_hole_spacing_mm"]) / 2.0 + + float(template["manufacturing"]["plate_hole_diameter_mm"]) / 2.0 + ) + margins["xyz".index(hardware_axis)] = max(margins["xyz".index(hardware_axis)], plate_margin) + tangent_clearances = [] + for axis in tangent_axes: + low = float(center[axis]) - float(host["position_mm"][axis]) + high = float(host["position_mm"][axis]) + float(host["size_mm"][axis]) - float(center[axis]) + clearance = min(low, high) - margins[axis] + tangent_clearances.append(clearance) + if clearance < -tolerance_mm: + errors.append(f"安装孔型在{'xyz'[axis].upper()}轴超出宿主边界") + if errors: + mounting_issue( + "hardware_mounting_geometry", instance_id, index, ";".join(errors), + host_instance_id=host_id, slot=slot, face=face, + mounting_center_mm=[round(value, 6) for value in center], + tangent_clearance_mm=[round(value, 6) for value in tangent_clearances], + correction="回看原图选择正确宿主面并移动mounting_center_mm;不得修改模板孔径、孔距或嵌入深度。", + ) + continue + row = { + "instance_id": instance_id, "host_instance_id": host_id, + "hardware_family": family, "hardware_template_id": template_id, + "approval_status": template["approval_status"], + "slot": slot, "kind": kind, "face": face, + "hardware_axis": hardware_axis or None, + "mounting_center_mm": [round(value, 6) for value in center], "declared_embed_depth_mm": declared_embed, - "actual_embed_depth_mm": actual_embed, + "actual_embed_depth_mm": required_embed, "gap_mm": gap, - "tangent_overlap_mm": tangent_overlaps, - "allows_host_interference": kind in {"mortised", "through"}, + "tangent_clearance_mm": tangent_clearances, + "allows_host_interference": kind == "mortised", + "template_geometry": True, "same_assembly": hardware.get("assembly_id") == host.get("assembly_id"), - "source": "declared_mounting_verified_by_aabb", - }) - return audits, issues + "source": "selected_hardware_template_verified_at_host_face", + } + audits.append(row) + instance_audits[slot] = row + missing_slots = sorted(set(slot_rules) - seen_slots) + if missing_slots: + mounting_issue( + "hardware_mounting_slots_missing", instance_id, None, + "五金模板缺少必需安装槽", missing_slots=missing_slots, + ) + if family == "concealed_cup_hinge" and set(instance_audits) == {"cup", "plate"}: + cup, plate = instance_audits["cup"], instance_audits["plate"] + hinge_axis = "xyz".index(hardware_axis) + if abs(cup["mounting_center_mm"][hinge_axis] - plate["mounting_center_mm"][hinge_axis]) > tolerance_mm: + mounting_issue( + "hardware_axis_alignment", instance_id, None, + "铰杯与底座中心必须位于同一铰链轴坐标", + cup_center_mm=cup["mounting_center_mm"], plate_center_mm=plate["mounting_center_mm"], + ) + cup_host = instances[cup["host_instance_id"]] + cup_normal = "xyz".index(cup["face"][0]) + lateral_axis = next(axis for axis in range(3) if axis not in {cup_normal, hinge_axis}) + cup_radius = float(template["manufacturing"]["cup_diameter_mm"]) / 2.0 + cup_edge_distance = min( + cup["mounting_center_mm"][lateral_axis] - float(cup_host["position_mm"][lateral_axis]), + float(cup_host["position_mm"][lateral_axis]) + float(cup_host["size_mm"][lateral_axis]) - cup["mounting_center_mm"][lateral_axis], + ) - cup_radius + edge_range = template["manufacturing"]["cup_edge_distance_range_mm"] + if cup_edge_distance < float(edge_range[0]) - tolerance_mm or cup_edge_distance > float(edge_range[1]) + tolerance_mm: + mounting_issue( + "hinge_cup_edge_distance", instance_id, None, + "铰杯孔边距不在所选模板允许范围", + actual_mm=round(cup_edge_distance, 6), allowed_mm=edge_range, + ) + plate_host = instances[plate["host_instance_id"]] + plate_normal = "xyz".index(plate["face"][0]) + system_axis = next(axis for axis in range(3) if axis not in {plate_normal, hinge_axis}) + system_line = min( + plate["mounting_center_mm"][system_axis] - float(plate_host["position_mm"][system_axis]), + float(plate_host["position_mm"][system_axis]) + float(plate_host["size_mm"][system_axis]) - plate["mounting_center_mm"][system_axis], + ) + expected_line = float(template["manufacturing"]["plate_system_line_mm"]) + if abs(system_line - expected_line) > tolerance_mm: + mounting_issue( + "hinge_plate_system_line", instance_id, None, + "铰链底座中心未落在模板系统线", + actual_mm=round(system_line, 6), required_mm=expected_line, + ) + if len(issues) == start_issue_count and set(instance_audits) == set(slot_rules): + try: + geometry[instance_id] = _build_hardware_template_geometry(hardware, template, instance_audits) + except (GraphError, KeyError, TypeError, ValueError) as exc: + mounting_issue("hardware_template_geometry", instance_id, None, str(exc)) + return audits, issues, geometry def _solve_anchored_axis_placements( @@ -1189,6 +1277,16 @@ def compile_graph( material_family = part.get("material_family") if material_family not in {None, "", *allowed_material_families}: raise GraphError("零件material_family不在受支持范围") + hardware_family = str(part.get("hardware_family") or "") + hardware_template_id = str(part.get("hardware_template_id") or "") + if role == "hardware": + template = get_hardware_template(hardware_template_id) + if not hardware_family or not hardware_template_id: + raise GraphError("五金必须声明hardware_family和hardware_template_id,禁止按中文名称猜模板") + if not template or template.get("family") != hardware_family: + raise GraphError("hardware_template_id不存在或与hardware_family不匹配") + elif hardware_family or hardware_template_id: + raise GraphError("非五金零件不得声明hardware_family或hardware_template_id") thickness_axis = part.get("thickness_axis") if role in panel_roles: if thickness_axis not in {"x", "y", "z"}: @@ -1292,6 +1390,8 @@ def compile_graph( "functional_role": parts[inst["part_id"]].get("functional_role"), "thickness_axis": product_thickness_axis, "material_family": parts[inst["part_id"]].get("material_family"), + "hardware_family": parts[inst["part_id"]].get("hardware_family"), + "hardware_template_id": parts[inst["part_id"]].get("hardware_template_id"), } if graph.get("placement_solver") == "anchored_axes_v1": instance_specs[key] = {**inst, **inherited, "size_mm": size} @@ -1311,17 +1411,23 @@ def compile_graph( ) except (GraphError, KeyError, TypeError) as exc: issue("placement_constraint", "/instances", str(exc)) - mounting_audit, mounting_issues = _validate_hardware_mountings( + mounting_audit, mounting_issues, hardware_geometry = _validate_hardware_mountings( instances, observations=observations, resolver=resolver, ) issues.extend(mounting_issues) + for instance_id, geometry in hardware_geometry.items(): + instance = instances[instance_id] + instance["declared_placeholder_position_mm"] = list(instance["position_mm"]) + instance["declared_placeholder_size_mm"] = list(instance["size_mm"]) + instance["position_mm"] = list(geometry["position_mm"]) + instance["size_mm"] = list(geometry["size_mm"]) valid_mount_pairs = { frozenset((row["instance_id"], row["host_instance_id"])): row for row in mounting_audit } ignored_interference_pairs = { pair for pair, row in valid_mount_pairs.items() - if row.get("allows_host_interference") + if row.get("allows_host_interference") or row.get("template_geometry") } valid_same_assembly_hardware = { row["instance_id"] for row in mounting_audit if row["same_assembly"] @@ -1785,7 +1891,9 @@ def compile_graph( if collision: collision_pairs.append(collision) mounting = valid_mount_pairs.get(frozenset((a["id"], b["id"]))) - if mounting and mounting["allows_host_interference"]: + if mounting and ( + mounting["allows_host_interference"] or mounting.get("template_geometry") + ): collision["accepted_as_mounting"] = True collision["mounting_kind"] = mounting["kind"] collision["declared_embed_depth_mm"] = mounting["declared_embed_depth_mm"] @@ -1954,6 +2062,7 @@ def compile_graph( "assemblies": list(assemblies.values()), "assembly_connection_audit": assembly_connection_audit, "mounting_audit": mounting_audit, + "hardware_geometry_by_instance": hardware_geometry, "collision_audit": collision_pairs, "dimension_ledger": dimension_ledger or {}, "occupied_envelope_mm": envelope, @@ -1962,6 +2071,8 @@ def compile_graph( "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")} + "material_family": part.get("material_family"), + "hardware_family": part.get("hardware_family"), + "hardware_template_id": part.get("hardware_template_id")} 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 index f3f8a44..e143995 100644 --- 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 @@ -114,38 +114,15 @@ class ManufacturingOracleTest(unittest.TestCase): self.assertFalse(result["ok"]) self.assertEqual(1200.0, result["issues"][0]["expected_mm"]) - def test_hardware_feedback_includes_atomic_compiler_verified_placement(self): + def test_template_hardware_feedback_never_moves_placeholder_box(self): from test_structure_graph import anchored_door_with_hardware graph = anchored_door_with_hardware() - graph["envelope_mm"] = [700, 100, 700] - graph["parts"][0]["size_mm"][0] = 680 - graph["parts"][1]["size_mm"][1] = 50 - graph["instances"][1]["mountings"][0].update({ - "kind": "through", "embed_depth_mm": 18, - }) - graph["assemblies"][0]["bounds_mm"].update({"x": [0, 700], "y": [20, 100]}) - graph["parts"].append({ - "id": "right-side", "name": "侧板", "shape": "box", "functional_role": "side", - "thickness_axis": "x", "size_mm": [20, 80, 700], "observed_quantity": 1, - }) - graph["instances"].append({ - "id": "right-side-instance", "part_id": "right-side", "assembly_id": "moving-panel", - "placement": { - "x": {"mode": "absolute", "value_mm": 680}, - "y": {"mode": "absolute", "value_mm": 20}, - "z": {"mode": "absolute", "value_mm": 0}, - }, - }) - graph["instances"][1]["mountings"].append({ - "host_instance_id": "right-side-instance", "kind": "surface", "face": "x-", - "embed_depth_mm": 0, "evidence_ids": ["view"], - }) + graph["instances"][1]["mountings"][0]["mounting_center_mm"] = [700, 20, 350] compiled = compile_graph(graph, source_image_ids={"original-front"}) issue = next( row for row in compiled["issues"] if row.get("code") == "hardware_mounting_geometry" - and row.get("host_instance_id") == "right-side-instance" ) enriched = enrich_hardware_feedback_with_verified_placements( graph, compiled, [issue], @@ -153,10 +130,67 @@ class ManufacturingOracleTest(unittest.TestCase): candidate, source_image_ids={"original-front"} ), ) - candidates = enriched[0]["compatible_placement_patches"] - self.assertTrue(candidates) - self.assertEqual(650.0, candidates[0]["patches"][0]["value"]["value_mm"]) - self.assertEqual("full_graph_recompile_strict_issue_reduction", candidates[0]["validation"]) + self.assertNotIn("compatible_placement_patches", enriched[0]) + self.assertEqual("template_mounting_centers_only", enriched[0]["repair_contract"]) + self.assertIn("mounting_center_mm", enriched[0]["correction"]) + self.assertIn("禁止移动1×1×1占位", enriched[0]["correction"]) + + def test_hinge_template_generates_exact_host_machining_and_one_purchase_line(self): + from test_structure_graph import anchored_hinged_door_with_template + + compiled = compile_graph( + anchored_hinged_door_with_template(), source_image_ids={"original-front"} + ) + self.assertTrue(compiled["ok"], compiled["issues"]) + plan = plan_manufacturing_features(compiled) + self.assertTrue(plan["ok"], plan["issues"]) + self.assertEqual("manufacturing_draft_unapproved_hardware", plan["release_status"]) + self.assertEqual(5, plan["cost_drivers"]["hole_operations"]) + self.assertEqual(1, plan["cost_drivers"]["hardware_instances"]) + self.assertEqual(1, plan["cost_drivers"]["hardware_template_instances"]) + self.assertEqual(1, len(plan["hardware_lines"])) + self.assertEqual("euro_hinge_35_th52_system32_v1", plan["hardware_lines"][0]["hardware_code"]) + self.assertEqual(1, plan["hardware_lines"][0]["quantity"]) + hole_types = [ + hole["hole_type"] + for rows in plan["features_by_instance"].values() + for hole in rows["holes"] + ] + self.assertEqual(1, hole_types.count("hinge_cup")) + self.assertEqual(2, hole_types.count("hinge_cup_fixing")) + self.assertEqual(2, hole_types.count("hinge_plate_fixing")) + self.assertTrue(all( + hole["source"] == "selected_hardware_template" + for rows in plan["features_by_instance"].values() for hole in rows["holes"] + )) + self.assertNotIn("hinge-instance", plan["manufacturing_geometry_by_instance"]) + self.assertNotIn( + "hinge", + {row["part_id"] for row in plan["manufacturing_bom"]}, + ) + self.assertEqual([], diagnostic_mounting_clearance_cuts(compiled)["audit"]) + source = cad_source(compiled, plan) + self.assertIn("Cylinder(17.5, 12.8", source) + self.assertIn("Compound(children=[hardware_2_1", source) + self.assertNotIn("Box(1.0, 1.0, 1.0", source) + ast.parse(source) + + def test_knob_template_generates_one_through_fastener_hole_without_body_charge(self): + from test_structure_graph import anchored_door_with_hardware + + compiled = compile_graph( + anchored_door_with_hardware(), source_image_ids={"original-front"} + ) + self.assertTrue(compiled["ok"], compiled["issues"]) + plan = plan_manufacturing_features(compiled) + holes = plan["features_by_instance"]["panel-instance"]["holes"] + self.assertTrue(plan["ok"], plan["issues"]) + self.assertEqual(1, len(holes)) + self.assertEqual("handle_fastener_through", holes[0]["hole_type"]) + self.assertEqual(4.5, holes[0]["diameter_mm"]) + self.assertEqual(18.0, holes[0]["depth_mm"]) + self.assertEqual(1, plan["cost_drivers"]["hardware_instances"]) + self.assertNotIn("fitting-instance", plan["manufacturing_geometry_by_instance"]) def test_probe_resolves_semantic_role_filename_aliases(self): with tempfile.TemporaryDirectory() as folder: 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 index c818fbc..cd863bb 100644 --- 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 @@ -94,9 +94,8 @@ def anchored_frame_with_drawer_assembly(): return graph -def anchored_door_with_hardware(*, kind="surface", embed_depth_mm=0, hardware_x=285): - """A product-independent moving panel with one explicitly mounted item.""" - hardware_y = 0 if kind == "surface" else 5 +def anchored_door_with_hardware(*, kind="surface", embed_depth_mm=0, hardware_x=300): + """A product-independent moving panel with one template-backed knob.""" return { "schema_version": SCHEMA_VERSION, "placement_solver": "anchored_axes_v1", @@ -114,9 +113,12 @@ def anchored_door_with_hardware(*, kind="surface", embed_depth_mm=0, hardware_x= }], "parts": [ {"id": "panel", "name": "任意运动板", "shape": "box", "functional_role": "door", - "thickness_axis": "y", "size_mm": [600, 18, 700], "observed_quantity": 1}, - {"id": "fitting", "name": "任意五金", "shape": "box", "functional_role": "hardware", - "thickness_axis": None, "size_mm": [30, 20, 30], "observed_quantity": 1}, + "thickness_axis": "y", "material_family": "engineered_panel", + "size_mm": [600, 18, 700], "observed_quantity": 1}, + {"id": "fitting", "name": "单孔旋钮", "shape": "box", "functional_role": "hardware", + "thickness_axis": None, "hardware_family": "knob", + "hardware_template_id": "knob_m4_surface_v1", + "size_mm": [1, 1, 1], "observed_quantity": 1}, ], "instances": [ {"id": "panel-instance", "part_id": "panel", "assembly_id": "moving-panel", @@ -127,13 +129,15 @@ def anchored_door_with_hardware(*, kind="surface", embed_depth_mm=0, hardware_x= }}, {"id": "fitting-instance", "part_id": "fitting", "assembly_id": "moving-panel", "placement": { - "x": {"mode": "absolute", "value_mm": hardware_x}, - "y": {"mode": "absolute", "value_mm": hardware_y}, - "z": {"mode": "absolute", "value_mm": 335}, + "x": {"mode": "absolute", "value_mm": 0}, + "y": {"mode": "absolute", "value_mm": 0}, + "z": {"mode": "absolute", "value_mm": 0}, }, "mountings": [{ + "slot": "body", "host_instance_id": "panel-instance", "kind": kind, "face": "y-", - "embed_depth_mm": embed_depth_mm, "evidence_ids": ["view"], + "embed_depth_mm": embed_depth_mm, + "mounting_center_mm": [hardware_x, 20, 350], "evidence_ids": ["view"], }]}, ], "connections": [], @@ -142,13 +146,89 @@ def anchored_door_with_hardware(*, kind="surface", embed_depth_mm=0, hardware_x= } +def anchored_hinged_door_with_template(*, cup_edge_mm=5, plate_system_line_mm=37): + """One generic inset door and side using a selected 35 mm hinge template.""" + cup_x = 18 + 17.5 + cup_edge_mm + return { + "schema_version": SCHEMA_VERSION, + "placement_solver": "anchored_axes_v1", + "orientation_solver": "role_axis_templates_v1", + "contact_derivation": "aabb_within_assembly_v1", + "observations": [{ + "id": "view", "source_type": "image", "image_id": "original-front", + "confidence": 0.95, + }], + "parameters": {}, + "envelope_mm": [618, 400, 700], + "assemblies": [ + {"id": "door-group", "role": "door", "evidence_ids": ["view"], + "bounds_mm": {"x": [18, 618], "y": [0, 18], "z": [0, 700]}}, + {"id": "carcass", "role": "carcass", "evidence_ids": ["view"], + "bounds_mm": {"x": [0, 18], "y": [18, 400], "z": [0, 700]}}, + ], + "parts": [ + {"id": "door", "name": "门板", "shape": "box", "functional_role": "door", + "thickness_axis": "y", "material_family": "engineered_panel", + "size_mm": [600, 18, 700], "observed_quantity": 1}, + {"id": "side", "name": "侧板", "shape": "box", "functional_role": "side", + "thickness_axis": "x", "material_family": "engineered_panel", + "size_mm": [18, 382, 700], "observed_quantity": 1}, + {"id": "hinge", "name": "35mm暗铰链", "shape": "box", "functional_role": "hardware", + "thickness_axis": None, "hardware_family": "concealed_cup_hinge", + "hardware_template_id": "euro_hinge_35_th52_system32_v1", + "size_mm": [1, 1, 1], "observed_quantity": 1}, + ], + "instances": [ + {"id": "door-instance", "part_id": "door", "assembly_id": "door-group", + "placement": { + "x": {"mode": "absolute", "value_mm": 18}, + "y": {"mode": "absolute", "value_mm": 0}, + "z": {"mode": "absolute", "value_mm": 0}, + }}, + {"id": "side-instance", "part_id": "side", "assembly_id": "carcass", + "placement": { + "x": {"mode": "absolute", "value_mm": 0}, + "y": {"mode": "absolute", "value_mm": 18}, + "z": {"mode": "absolute", "value_mm": 0}, + }}, + {"id": "hinge-instance", "part_id": "hinge", "assembly_id": "door-group", + "hardware_axis": "z", + "placement": { + "x": {"mode": "absolute", "value_mm": 0}, + "y": {"mode": "absolute", "value_mm": 0}, + "z": {"mode": "absolute", "value_mm": 0}, + }, + "mountings": [ + {"slot": "cup", "host_instance_id": "door-instance", "kind": "mortised", + "face": "y+", "embed_depth_mm": 12.8, + "mounting_center_mm": [cup_x, 18, 100], "evidence_ids": ["view"]}, + {"slot": "plate", "host_instance_id": "side-instance", "kind": "surface", + "face": "x+", "embed_depth_mm": 0, + "mounting_center_mm": [18, 18 + plate_system_line_mm, 100], "evidence_ids": ["view"]}, + ]}, + ], + "connections": [], + "assembly_connections": [{ + "id": "door-hinge", "kind": "hinged", "a": "door-group", "b": "carcass", + "axis": "z", "angle_limits_deg": [0, 110], "evidence_ids": ["view"], + }], + "uncertainties": [], + } + + class StructureGraphTest(unittest.TestCase): - def test_surface_mounted_hardware_can_protrude_beyond_structural_assembly_bounds(self): + def test_template_knob_can_protrude_beyond_structural_assembly_bounds(self): result = compile_case(anchored_door_with_hardware()) self.assertTrue(result["ok"], result["issues"]) self.assertEqual(1, len(result["mounting_audit"])) self.assertEqual("surface", result["mounting_audit"][0]["kind"]) self.assertEqual(0, result["mounting_audit"][0]["actual_embed_depth_mm"]) + geometry = result["hardware_geometry_by_instance"]["fitting-instance"] + self.assertEqual("knob", geometry["hardware_family"]) + self.assertEqual(["knob_body"], [row["role"] for row in geometry["primitives"]]) + fitting = next(row for row in result["instances"] if row["id"] == "fitting-instance") + self.assertEqual([1.0, 1.0, 1.0], fitting["declared_placeholder_size_mm"]) + self.assertEqual([20.0, 24.0, 20.0], fitting["size_mm"]) self.assertNotIn( "assembly_bounds_not_tight", {row["code"] for row in result["issues"]} ) @@ -161,28 +241,21 @@ class StructureGraphTest(unittest.TestCase): self.assertIn("disconnected_instance", codes) self.assertFalse(result["mounting_audit"]) - def test_hardware_entirely_beyond_host_inner_face_cannot_fake_through_mounting(self): + def test_surface_knob_cannot_fake_whole_body_through_mounting(self): graph = anchored_door_with_hardware(kind="through", embed_depth_mm=18) - graph["instances"][1]["placement"]["y"]["value_mm"] = 50 result = compile_case(graph) - problem = next( - row for row in result["issues"] if row["code"] == "hardware_mounting_geometry" - ) - self.assertGreater(problem["gap_mm"], 0) - self.assertEqual(0, problem["actual_embed_depth_mm"]) + problem = next(row for row in result["issues"] if row["code"] == "hardware_mounting_kind") + self.assertEqual("surface", problem["required_kind"]) + self.assertEqual("through", problem["actual_kind"]) self.assertFalse(result["mounting_audit"]) - def test_mortised_hardware_allows_only_declared_host_penetration(self): - result = compile_case(anchored_door_with_hardware(kind="mortised", embed_depth_mm=5)) - self.assertTrue(result["ok"], result["issues"]) - self.assertEqual(5, result["mounting_audit"][0]["actual_embed_depth_mm"]) - accepted = [row for row in result["collision_audit"] if row.get("accepted_as_mounting")] - self.assertEqual(1, len(accepted)) - - too_deep = anchored_door_with_hardware(kind="mortised", embed_depth_mm=2) - broken = compile_case(too_deep) - self.assertIn("hardware_mounting_geometry", {row["code"] for row in broken["issues"]}) - self.assertIn("solid_interference", {row["code"] for row in broken["issues"]}) + def test_hardware_family_and_template_are_required_and_must_match(self): + missing = anchored_door_with_hardware() + missing["parts"][1].pop("hardware_template_id") + self.assertIn("part_geometry", {row["code"] for row in compile_case(missing)["issues"]}) + mismatch = anchored_door_with_hardware() + mismatch["parts"][1]["hardware_family"] = "concealed_cup_hinge" + self.assertIn("part_geometry", {row["code"] for row in compile_case(mismatch)["issues"]}) def test_hardware_mounting_is_required_and_non_hardware_cannot_claim_it(self): missing = anchored_door_with_hardware() @@ -193,7 +266,7 @@ class StructureGraphTest(unittest.TestCase): invalid["instances"][0]["mountings"] = copy.deepcopy(invalid["instances"][1]["mountings"]) self.assertIn("mounting_on_non_hardware", {row["code"] for row in compile_case(invalid)["issues"]}) - def test_panel_hardware_feedback_only_suggests_real_broad_faces(self): + def test_template_hardware_feedback_requires_real_broad_face_not_box_movement(self): graph = anchored_door_with_hardware() graph["instances"][1]["mountings"][0]["face"] = "x+" result = compile_case(graph) @@ -201,10 +274,34 @@ class StructureGraphTest(unittest.TestCase): row for row in result["issues"] if row["code"] == "hardware_mounting_geometry" ) self.assertIn("厚度轴y的广面", problem["detail"]) - self.assertTrue(problem["compatible_mounting_values"]) + self.assertNotIn("compatible_mounting_values", problem) + + def test_hinge_template_validates_slots_system_lines_and_non_box_geometry(self): + result = compile_case(anchored_hinged_door_with_template()) + self.assertTrue(result["ok"], result["issues"]) + self.assertEqual({"cup", "plate"}, {row["slot"] for row in result["mounting_audit"]}) + geometry = result["hardware_geometry_by_instance"]["hinge-instance"] self.assertEqual( - {"y-"}, {row["face"] for row in problem["compatible_mounting_values"]} + {"hinge_cup", "hinge_mounting_plate", "hinge_arm_from_cup", "hinge_arm_to_plate"}, + {row["role"] for row in geometry["primitives"]}, ) + self.assertIn("cylinder", {row["kind"] for row in geometry["primitives"]}) + self.assertTrue(all(row.get("accepted_as_mounting") for row in result["collision_audit"])) + self.assertEqual([], result["derived_contacts"]) + + def test_hinge_template_blocks_wrong_cup_edge_and_plate_system_line(self): + cup = compile_case(anchored_hinged_door_with_template(cup_edge_mm=15)) + self.assertIn("hinge_cup_edge_distance", {row["code"] for row in cup["issues"]}) + plate = compile_case(anchored_hinged_door_with_template(plate_system_line_mm=55)) + self.assertIn("hinge_plate_system_line", {row["code"] for row in plate["issues"]}) + + def test_hinge_template_requires_every_mounting_slot_and_template_kind(self): + missing = anchored_hinged_door_with_template() + missing["instances"][2]["mountings"].pop() + self.assertIn("hardware_mounting_slots_missing", {row["code"] for row in compile_case(missing)["issues"]}) + wrong = anchored_hinged_door_with_template() + wrong["instances"][2]["mountings"][0]["kind"] = "through" + self.assertIn("hardware_mounting_kind", {row["code"] for row in compile_case(wrong)["issues"]}) def test_role_axis_mode_accepts_direct_product_axes_without_instance_mapping(self): graph = anchored_frame()