446 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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