446 lines
23 KiB
Python
446 lines
23 KiB
Python
"""Geometry-driven manufacturing feature planner for compiled assemblies.
|
||
|
||
The image model identifies physical parts and assembly membership. This
|
||
module turns verified geometric contacts into holes, grooves or weld seams.
|
||
It never infers a joint from a Chinese display name and never silently clamps
|
||
an invalid feature into the part. The output remains a manufacturing draft;
|
||
an independent B-rep/section inspection is still required before release.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from collections import Counter
|
||
|
||
|
||
SCHEMA_VERSION = "dalongxia.manufacturing-feature-plan/1"
|
||
|
||
PANEL_ROLES = {
|
||
"top", "bottom", "side", "shelf", "door", "back", "drawer_front",
|
||
"drawer_left", "drawer_right", "drawer_back", "drawer_bottom",
|
||
"drawer_support", "divider",
|
||
}
|
||
CAPTURED_ROLES = {"back", "drawer_bottom"}
|
||
ALLOWED_MATERIALS = {"engineered_panel", "solid_wood", "metal", "plastic", "glass", "other"}
|
||
|
||
DEFAULT_POLICY = {
|
||
"default_material_family": "engineered_panel",
|
||
"assembly_mode": "knock_down",
|
||
"engineered_panel_joint": "confirmat",
|
||
"solid_wood_joint": "wood_screw",
|
||
"mixed_joint": "bolt",
|
||
"metal_joint": "weld",
|
||
"shelf_joint": "shelf_pin",
|
||
"captured_panel_joint": "captured_groove",
|
||
"max_connector_spacing_mm": 240.0,
|
||
"connector_edge_offset_mm": 45.0,
|
||
"groove_clearance_mm": 0.5,
|
||
}
|
||
|
||
|
||
def _finite_positive(value, label: str) -> float:
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
|
||
raise ValueError(f"{label}必须为有限正数")
|
||
return float(value)
|
||
|
||
|
||
def _axis_index(face: str) -> int:
|
||
if not isinstance(face, str) or len(face) != 2 or face[0] not in "xyz" or face[1] not in "+-":
|
||
raise ValueError("连接面必须为x-/x+/y-/y+/z-/z+")
|
||
return "xyz".index(face[0])
|
||
|
||
|
||
def _face_plane(instance: dict, face: str) -> float:
|
||
axis = _axis_index(face)
|
||
return float(instance["position_mm"][axis]) + (float(instance["size_mm"][axis]) if face[1] == "+" else 0.0)
|
||
|
||
|
||
def _inside_center(instance: dict, face: str, depth: float) -> list[float]:
|
||
axis = _axis_index(face)
|
||
center = [float(instance["position_mm"][i]) + float(instance["size_mm"][i]) / 2.0 for i in range(3)]
|
||
plane = _face_plane(instance, face)
|
||
center[axis] = plane - depth / 2.0 if face[1] == "+" else plane + depth / 2.0
|
||
return center
|
||
|
||
|
||
def _local_center(instance: dict, world_center: list[float]) -> dict:
|
||
axis_order = list(instance.get("axis_order") or [0, 1, 2])
|
||
local = [0.0, 0.0, 0.0]
|
||
for product_axis in range(3):
|
||
local_axis = axis_order[product_axis]
|
||
local[local_axis] = float(world_center[product_axis]) - float(instance["position_mm"][product_axis])
|
||
return {"x": round(local[0], 4), "y": round(local[1], 4), "z": round(local[2], 4)}
|
||
|
||
|
||
def _world_dict(values: list[float]) -> dict:
|
||
return {axis: round(float(values[index]), 4) for index, axis in enumerate("xyz")}
|
||
|
||
|
||
def _overlap_ranges(a: dict, b: dict, normal_axis: int) -> dict[int, tuple[float, float]]:
|
||
ranges = {}
|
||
for axis in range(3):
|
||
if axis == normal_axis:
|
||
continue
|
||
low = max(float(a["position_mm"][axis]), float(b["position_mm"][axis]))
|
||
high = min(
|
||
float(a["position_mm"][axis]) + float(a["size_mm"][axis]),
|
||
float(b["position_mm"][axis]) + float(b["size_mm"][axis]),
|
||
)
|
||
ranges[axis] = (low, high)
|
||
return ranges
|
||
|
||
|
||
def _line_points(
|
||
a: dict,
|
||
b: dict,
|
||
normal_axis: int,
|
||
policy: dict,
|
||
*,
|
||
face_a: str,
|
||
face_b: str,
|
||
fixed_count: int | None = None,
|
||
) -> list[list[float]]:
|
||
ranges = _overlap_ranges(a, b, normal_axis)
|
||
if any(high - low <= 0.01 for low, high in ranges.values()):
|
||
return []
|
||
run_axis = max(ranges, key=lambda axis: ranges[axis][1] - ranges[axis][0])
|
||
cross_axis = next(axis for axis in ranges if axis != run_axis)
|
||
low, high = ranges[run_axis]
|
||
length = high - low
|
||
edge = min(float(policy["connector_edge_offset_mm"]), max(0.0, length / 3.0))
|
||
usable = max(0.0, length - 2 * edge)
|
||
if fixed_count is not None:
|
||
count = max(1, int(fixed_count))
|
||
else:
|
||
spacing = _finite_positive(policy["max_connector_spacing_mm"], "最大连接件间距")
|
||
count = max(2, int(math.ceil(usable / spacing)) + 1)
|
||
positions = [low + length / 2.0] if count == 1 else [low + edge + usable * i / (count - 1) for i in range(count)]
|
||
plane = (_face_plane(a, face_a) + _face_plane(b, face_b)) / 2.0
|
||
points = []
|
||
for coordinate in positions:
|
||
point = [0.0, 0.0, 0.0]
|
||
point[normal_axis] = plane
|
||
point[run_axis] = coordinate
|
||
point[cross_axis] = sum(ranges[cross_axis]) / 2.0
|
||
points.append(point)
|
||
return points
|
||
|
||
|
||
def _validate_hole(instance: dict, feature: dict) -> list[str]:
|
||
messages = []
|
||
axis = "xyz".index(feature["axis"])
|
||
center = [feature["world_center_mm"][name] for name in "xyz"]
|
||
radius = float(feature["diameter_mm"]) / 2.0
|
||
depth = float(feature["depth_mm"])
|
||
for tangent in range(3):
|
||
low = float(instance["position_mm"][tangent])
|
||
high = low + float(instance["size_mm"][tangent])
|
||
margin = 0.0 if tangent == axis else radius
|
||
if center[tangent] - margin < low - 0.01 or center[tangent] + margin > high + 0.01:
|
||
messages.append(f"孔圆周超出零件{'xyz'[tangent].upper()}轴边界")
|
||
if depth > float(instance["size_mm"][axis]) + 0.01:
|
||
messages.append("孔深超过零件厚度/轴向尺寸")
|
||
expected = _inside_center(instance, feature["face"], depth)
|
||
if abs(center[axis] - expected[axis]) > 0.05:
|
||
messages.append("孔中心与声明加工面、孔深不一致")
|
||
return messages
|
||
|
||
|
||
def _method_for(contact: dict, instances: dict, policy: dict) -> str:
|
||
a, b = instances[contact["a"]], instances[contact["b"]]
|
||
roles = {a.get("functional_role"), b.get("functional_role")}
|
||
materials = {a.get("material_family"), b.get("material_family")}
|
||
if roles & CAPTURED_ROLES:
|
||
return str(policy["captured_panel_joint"])
|
||
if "shelf" in roles:
|
||
return str(policy["shelf_joint"])
|
||
if materials == {"metal"}:
|
||
return str(policy["metal_joint"])
|
||
if "metal" in materials:
|
||
return str(policy["mixed_joint"])
|
||
if "engineered_panel" in materials:
|
||
return str(policy["engineered_panel_joint"])
|
||
if materials == {"solid_wood"}:
|
||
return str(policy["solid_wood_joint"])
|
||
return "unresolved"
|
||
|
||
|
||
def plan_manufacturing_features(compiled: dict, *, policy: dict | None = None) -> dict:
|
||
"""Return a deterministic manufacturing draft from a compiled graph."""
|
||
active_policy = {**DEFAULT_POLICY, **dict(policy or {})}
|
||
issues: list[dict] = []
|
||
|
||
def issue(code: str, detail: str, **context) -> None:
|
||
issues.append({"code": code, "detail": detail, "severity": "blocking", **context})
|
||
|
||
if not isinstance(compiled, dict) or not compiled.get("ok"):
|
||
return {
|
||
"schema_version": SCHEMA_VERSION,
|
||
"ok": False,
|
||
"release_status": "blocked_by_assembly_graph",
|
||
"issues": [{"code": "assembly_graph_not_valid", "detail": "装配图谱未通过,禁止生成孔槽", "severity": "blocking"}],
|
||
"features_by_instance": {}, "connection_plans": [], "hardware_lines": [],
|
||
}
|
||
default_material = str(active_policy.get("default_material_family") or "")
|
||
if default_material not in ALLOWED_MATERIALS:
|
||
raise ValueError("默认材料族无效")
|
||
instances = {str(row["id"]): dict(row) for row in compiled.get("instances") or []}
|
||
for row in instances.values():
|
||
material = str(row.get("material_family") or default_material)
|
||
if material not in ALLOWED_MATERIALS:
|
||
issue("material_family", "实例材料族无效", instance_id=row["id"], material_family=material)
|
||
row["material_family"] = material
|
||
features = {key: {"holes": [], "grooves": [], "welds": []} for key in instances}
|
||
# The assembly graph describes visible/net contact geometry. A captured
|
||
# back or drawer bottom must extend into the receiver after its groove is
|
||
# cut. Keep this manufacturing geometry separate and auditable instead
|
||
# of either leaving the insert at the groove mouth or hiding a positive
|
||
# AABB overlap in the source graph.
|
||
manufacturing_geometry = {
|
||
key: {
|
||
"instance_id": key,
|
||
"part_id": row.get("part_id"),
|
||
"axis_order": list(row.get("axis_order") or [0, 1, 2]),
|
||
"position_mm": [float(value) for value in row["position_mm"]],
|
||
"size_mm": [float(value) for value in row["size_mm"]],
|
||
"capture_allowances": [],
|
||
}
|
||
for key, row in instances.items()
|
||
}
|
||
connection_plans = []
|
||
hardware = Counter()
|
||
hardware_names = {
|
||
"confirmat": ("确认螺钉", "7×50mm"),
|
||
"wood_screw": ("木螺钉", "4×40mm"),
|
||
"shelf_pin": ("层板托", "5mm"),
|
||
"bolt": ("连接螺栓", "M6"),
|
||
}
|
||
sequence = 0
|
||
|
||
def add_hole(instance: dict, *, point: list[float], face: str, diameter: float, depth: float,
|
||
connection_id: str, hardware_id: str, hole_type: str) -> None:
|
||
nonlocal sequence
|
||
sequence += 1
|
||
axis_index = _axis_index(face)
|
||
center = _inside_center(instance, face, depth)
|
||
for tangent in range(3):
|
||
if tangent != axis_index:
|
||
center[tangent] = point[tangent]
|
||
feature = {
|
||
"feature_id": f"HOLE-{sequence:05d}", "kind": "hole",
|
||
"manufacturing_connection_id": connection_id, "hardware_instance_id": hardware_id,
|
||
"instance_id": instance["id"], "axis": "xyz"[axis_index], "face": face,
|
||
"world_center_mm": _world_dict(center), "local_center_mm": _local_center(instance, center),
|
||
"diameter_mm": round(float(diameter), 3), "depth_mm": round(float(depth), 3),
|
||
"hole_type": hole_type, "source": "verified_geometric_contact",
|
||
}
|
||
errors = _validate_hole(instance, feature)
|
||
feature["validation_status"] = "blocked" if errors else "valid"
|
||
if errors:
|
||
issue("hole_geometry", ";".join(errors), instance_id=instance["id"], feature_id=feature["feature_id"])
|
||
features[instance["id"]]["holes"].append(feature)
|
||
|
||
for contact in compiled.get("derived_contacts") or []:
|
||
a, b = instances.get(str(contact.get("a"))), instances.get(str(contact.get("b")))
|
||
if not a or not b:
|
||
issue("contact_target", "制造接触引用了不存在的实例", contact_id=contact.get("id"))
|
||
continue
|
||
try:
|
||
normal_axis = _axis_index(str(contact.get("face_a") or ""))
|
||
except ValueError as exc:
|
||
issue("contact_face", str(exc), contact_id=contact.get("id"))
|
||
continue
|
||
method = _method_for(contact, instances, active_policy)
|
||
connection_id = str(contact.get("id") or f"contact:{a['id']}:{b['id']}")
|
||
plan = {"connection_id": connection_id, "a": a["id"], "b": b["id"], "method": method,
|
||
"interface_type": contact.get("interface_type"), "feature_ids": [], "hardware_instance_ids": []}
|
||
if method == "mortise_tenon" and "engineered_panel" in {a["material_family"], b["material_family"]}:
|
||
issue("material_process_incompatible", "人造板接触不得使用传统承力榫卯", contact_id=connection_id)
|
||
connection_plans.append(plan)
|
||
continue
|
||
if method == "unresolved" or contact.get("interface_type") == "edge_to_edge" and method not in {"weld"}:
|
||
issue("joint_method_unresolved", "该接触界面没有可靠的量产连接方式", contact_id=connection_id,
|
||
interface_type=contact.get("interface_type"), material_families=[a["material_family"], b["material_family"]])
|
||
connection_plans.append(plan)
|
||
continue
|
||
if method == "captured_groove":
|
||
insert = a if a.get("functional_role") in CAPTURED_ROLES else b if b.get("functional_role") in CAPTURED_ROLES else None
|
||
receiver_id = contact.get("broad_face_instance_id")
|
||
receiver = instances.get(str(receiver_id)) if receiver_id else None
|
||
if not insert or not receiver or insert["id"] == receiver["id"]:
|
||
issue("groove_receiver_unresolved", "嵌入薄板接触无法确定开槽承载件", contact_id=connection_id)
|
||
connection_plans.append(plan)
|
||
continue
|
||
if (
|
||
contact.get("interface_type") != "edge_to_face"
|
||
or str(contact.get("edge_instance_id") or "") != insert["id"]
|
||
):
|
||
issue(
|
||
"captured_interface_incompatible",
|
||
"嵌入薄板必须以板边进入承载件宽面;宽面对宽面接触不能自动生成捕获槽",
|
||
contact_id=connection_id,
|
||
insert_instance_id=insert["id"],
|
||
interface_type=contact.get("interface_type"),
|
||
)
|
||
connection_plans.append(plan)
|
||
continue
|
||
insert_thickness_axis = insert.get("thickness_axis")
|
||
if insert_thickness_axis not in "xyz":
|
||
issue("insert_thickness_axis", "嵌入板未声明产品坐标厚度轴", contact_id=connection_id)
|
||
connection_plans.append(plan)
|
||
continue
|
||
insert_thickness = float(insert["size_mm"]["xyz".index(insert_thickness_axis)])
|
||
clearance = float(active_policy["groove_clearance_mm"])
|
||
receiver_face = contact["face_a"] if contact["a"] == receiver["id"] else contact["face_b"]
|
||
depth = min(6.0, float(receiver["size_mm"][normal_axis]) * 0.4)
|
||
ranges = _overlap_ranges(a, b, normal_axis)
|
||
run_axis = max(ranges, key=lambda axis: ranges[axis][1] - ranges[axis][0])
|
||
cross_axis = next(axis for axis in ranges if axis != run_axis)
|
||
world_size = [depth if axis == normal_axis else 0.0 for axis in range(3)]
|
||
world_size[run_axis] = ranges[run_axis][1] - ranges[run_axis][0]
|
||
world_size[cross_axis] = insert_thickness + clearance
|
||
center = _inside_center(receiver, receiver_face, depth)
|
||
center[run_axis] = sum(ranges[run_axis]) / 2.0
|
||
center[cross_axis] = float(insert["position_mm"][cross_axis]) + float(insert["size_mm"][cross_axis]) / 2.0
|
||
sequence += 1
|
||
groove = {
|
||
"feature_id": f"GROOVE-{sequence:05d}", "kind": "groove",
|
||
"manufacturing_connection_id": connection_id, "instance_id": receiver["id"],
|
||
"face": receiver_face, "world_center_mm": _world_dict(center),
|
||
"world_size_mm": _world_dict(world_size), "insert_instance_id": insert["id"],
|
||
"insert_thickness_mm": round(insert_thickness, 3), "clearance_mm": round(clearance, 3),
|
||
"source": "verified_geometric_contact", "validation_status": "valid",
|
||
}
|
||
receiver_low = receiver["position_mm"]
|
||
for axis in range(3):
|
||
if center[axis] - world_size[axis] / 2 < receiver_low[axis] - 0.01 or center[axis] + world_size[axis] / 2 > receiver_low[axis] + receiver["size_mm"][axis] + 0.01:
|
||
groove["validation_status"] = "blocked"
|
||
issue("groove_geometry", "槽体超出承载零件边界", contact_id=connection_id, instance_id=receiver["id"])
|
||
break
|
||
features[receiver["id"]]["grooves"].append(groove)
|
||
plan["feature_ids"].append(groove["feature_id"])
|
||
insert_face = contact["face_a"] if contact["a"] == insert["id"] else contact["face_b"]
|
||
insert_axis = _axis_index(insert_face)
|
||
adjusted = manufacturing_geometry[insert["id"]]
|
||
if insert_face[1] == "-":
|
||
adjusted["position_mm"][insert_axis] -= depth
|
||
adjusted["size_mm"][insert_axis] += depth
|
||
allowance = {
|
||
"connection_id": connection_id,
|
||
"axis": "xyz"[insert_axis],
|
||
"face": insert_face,
|
||
"extension_mm": round(depth, 4),
|
||
"receiver_instance_id": receiver["id"],
|
||
"groove_feature_id": groove["feature_id"],
|
||
}
|
||
adjusted["capture_allowances"].append(allowance)
|
||
plan["insert_allowance"] = allowance
|
||
elif method == "weld":
|
||
ranges = _overlap_ranges(a, b, normal_axis)
|
||
length = max(high - low for low, high in ranges.values())
|
||
sequence += 1
|
||
weld = {"feature_id": f"WELD-{sequence:05d}", "kind": "weld_seam",
|
||
"manufacturing_connection_id": connection_id, "length_mm": round(length, 3),
|
||
"process": "fillet_weld", "source": "verified_metal_contact"}
|
||
features[a["id"]]["welds"].append(weld)
|
||
plan["feature_ids"].append(weld["feature_id"])
|
||
else:
|
||
if contact.get("interface_type") != "edge_to_face":
|
||
issue("connector_interface", "螺钉/层板托/螺栓模板只允许边对面接触", contact_id=connection_id,
|
||
interface_type=contact.get("interface_type"))
|
||
connection_plans.append(plan)
|
||
continue
|
||
broad = instances[str(contact["broad_face_instance_id"])]
|
||
edge = instances[str(contact["edge_instance_id"])]
|
||
broad_face = contact["face_a"] if contact["a"] == broad["id"] else contact["face_b"]
|
||
edge_face = contact["face_a"] if contact["a"] == edge["id"] else contact["face_b"]
|
||
fixed_count = 2 if method == "shelf_pin" else None
|
||
points = _line_points(
|
||
a, b, normal_axis, active_policy,
|
||
face_a=contact["face_a"], face_b=contact["face_b"], fixed_count=fixed_count,
|
||
)
|
||
if not points:
|
||
issue("connector_span", "接触面没有足够的正面积布置连接件", contact_id=connection_id)
|
||
connection_plans.append(plan)
|
||
continue
|
||
diameter = 5.0 if method == "shelf_pin" else 6.0 if method == "bolt" else 5.0 if method == "confirmat" else 3.0
|
||
for ordinal, point in enumerate(points, 1):
|
||
hardware_id = f"HW:{connection_id}:{ordinal}"
|
||
broad_depth = float(broad["size_mm"][normal_axis])
|
||
add_hole(broad, point=point, face=broad_face, diameter=diameter, depth=broad_depth,
|
||
connection_id=connection_id, hardware_id=hardware_id, hole_type="through")
|
||
if method != "shelf_pin":
|
||
edge_depth = min(40.0 if method == "confirmat" else 30.0, float(edge["size_mm"][normal_axis]) - 1.0)
|
||
if edge_depth <= 0:
|
||
issue("connector_depth", "边部件轴向尺寸不足以容纳盲孔", contact_id=connection_id, instance_id=edge["id"])
|
||
else:
|
||
add_hole(edge, point=point, face=edge_face, diameter=max(2.5, diameter - 1.0), depth=edge_depth,
|
||
connection_id=connection_id, hardware_id=hardware_id, hole_type="blind")
|
||
hardware[method] += 1
|
||
plan["hardware_instance_ids"].append(hardware_id)
|
||
plan["feature_ids"] = [
|
||
row["feature_id"] for instance_features in features.values()
|
||
for row in instance_features["holes"] + instance_features["grooves"] + instance_features["welds"]
|
||
if row.get("manufacturing_connection_id") == connection_id
|
||
]
|
||
connection_plans.append(plan)
|
||
|
||
# A duplicated operation would be machined and charged twice. Detect it
|
||
# by geometry and instance, independent of feature names.
|
||
for instance_id, rows in features.items():
|
||
seen = {}
|
||
for feature in rows["holes"]:
|
||
signature = (feature["axis"], tuple(feature["world_center_mm"][a] for a in "xyz"), feature["diameter_mm"], feature["depth_mm"])
|
||
if signature in seen:
|
||
issue("duplicate_feature", "同一孔位被重复生成,禁止重复加工和计费", instance_id=instance_id,
|
||
feature_ids=[seen[signature], feature["feature_id"]])
|
||
seen[signature] = feature["feature_id"]
|
||
|
||
hardware_lines = [
|
||
{"hardware_code": method, "name": hardware_names[method][0], "specification": hardware_names[method][1],
|
||
"quantity": quantity, "unit": "个", "quantity_source": "unique_hardware_instance_id"}
|
||
for method, quantity in sorted(hardware.items()) if quantity and method in hardware_names
|
||
]
|
||
hole_count = sum(len(row["holes"]) for row in features.values())
|
||
groove_count = sum(len(row["grooves"]) for row in features.values())
|
||
weld_length = sum(float(feature["length_mm"]) for row in features.values() for feature in row["welds"])
|
||
bom_names = {str(row.get("part_id")): str(row.get("name") or row.get("part_id")) for row in compiled.get("bom") or []}
|
||
manufacturing_bom_groups: dict[tuple, dict] = {}
|
||
for instance_id, geometry in manufacturing_geometry.items():
|
||
axis_order = geometry["axis_order"]
|
||
local_size = [0.0, 0.0, 0.0]
|
||
for product_axis, local_axis in enumerate(axis_order):
|
||
local_size[local_axis] = float(geometry["size_mm"][product_axis])
|
||
local_size = [round(value, 4) for value in local_size]
|
||
signature = (str(geometry.get("part_id") or ""), tuple(local_size))
|
||
if signature not in manufacturing_bom_groups:
|
||
part_id = signature[0]
|
||
manufacturing_bom_groups[signature] = {
|
||
"part_id": part_id,
|
||
"name": bom_names.get(part_id, part_id),
|
||
"manufacturing_size_mm": local_size,
|
||
"quantity": 0,
|
||
"instance_ids": [],
|
||
"size_source": "compiled_net_geometry_plus_verified_capture_allowances",
|
||
}
|
||
manufacturing_bom_groups[signature]["quantity"] += 1
|
||
manufacturing_bom_groups[signature]["instance_ids"].append(instance_id)
|
||
return {
|
||
"schema_version": SCHEMA_VERSION,
|
||
"ok": not issues,
|
||
"release_status": "manufacturing_draft" if not issues else "blocked",
|
||
"policy": active_policy,
|
||
"issues": issues,
|
||
"features_by_instance": features,
|
||
"manufacturing_geometry_by_instance": manufacturing_geometry,
|
||
"manufacturing_bom": list(manufacturing_bom_groups.values()),
|
||
"connection_plans": connection_plans,
|
||
"hardware_lines": hardware_lines,
|
||
"cost_drivers": {
|
||
"hole_operations": hole_count,
|
||
"groove_operations": groove_count,
|
||
"weld_length_m": round(weld_length / 1000.0, 4),
|
||
"hardware_instances": sum(hardware.values()),
|
||
},
|
||
"validation_required": ["brep_boolean", "section_oracle", "assembly_interference", "visual_source_comparison"],
|
||
}
|