feat: replace generic fittings with hardware templates

This commit is contained in:
chenpei 2026-09-08 10:10:37 +08:00
parent 47ef7bbed9
commit 88cd1dc427
7 changed files with 838 additions and 282 deletions

View File

@ -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%仍未完成;候选不能部署或标记成功。

View File

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

View File

@ -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"],
}

View File

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

View File

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

View File

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

View File

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