"""Product-independent, evidence-backed parametric assembly compiler. This candidate does not infer geometry from Chinese part names. AI proposes a graph; this module resolves typed dimensions, derives BOM quantities from real instances and reports geometric contradictions for the AI to repair. It is an isolated compiler, NOT a certificate that the images or design are correct. """ from __future__ import annotations import ast import hashlib import json import math import re import copy from collections import Counter SCHEMA_VERSION = "furniture-assembly-graph/1" def build_source_topology_contract(source_structure: dict, *, min_confidence: float = 0.82) -> dict: """Promote only strongly evidenced source-part counts into a compile gate. This is image-derived evidence, never a company-BOM answer. It prevents a later graph-generation pass from silently omitting a part already counted by an independent multi-view pass. Invisible or low-confidence structure deliberately remains outside the hard contract. """ allowed_roles = { "top", "bottom", "side", "shelf", "door", "back", "drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", "drawer_support", "divider", "leg", "rail", "stile", "frame_member", "hardware", } role_counts = Counter() evidence: dict[str, list[dict]] = {} rows = source_structure.get("parts") if isinstance(source_structure, dict) else [] for row in rows or []: if not isinstance(row, dict): continue role = str(row.get("component_role") or row.get("functional_role") or "") confidence = row.get("confidence") image_ids = [ value for value in (row.get("image_ids") or []) if isinstance(value, int) and not isinstance(value, bool) ] quantity = row.get("quantity") if ( role not in allowed_roles or isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not math.isfinite(confidence) or float(confidence) < float(min_confidence) or not image_ids or isinstance(quantity, bool) or not isinstance(quantity, int) or quantity <= 0 ): continue role_counts[role] += quantity evidence.setdefault(role, []).append({ "source_structure_id": str(row.get("source_structure_id") or ""), "image_ids": sorted(set(image_ids)), "confidence": round(float(confidence), 4), "quantity": quantity, }) return { "source": "independent_multiview_source_structure", "minimum_confidence": float(min_confidence), "role_counts": dict(sorted(role_counts.items())), "evidence": evidence, } class GraphError(ValueError): pass def build_dimension_ledger(labels: list[dict], *, source_image_id: str) -> dict: """Accept only unambiguous overall arrows read from the source drawing. Multiple equal readings are corroboration; materially different readings on the same axis are retained as a conflict and deliberately not converted into a guessed CAD constraint. """ unit_scale = {"mm": 1.0, "cm": 10.0, "m": 1000.0} candidates = {axis: [] for axis in "xyz"} rejected = [] for index, row in enumerate(labels or []): if not isinstance(row, dict): continue axis, unit = row.get("axis"), row.get("unit") confidence = row.get("confidence") value = row.get("value") if ( axis not in candidates or row.get("scope") != "overall" or unit not in unit_scale or isinstance(value, bool) or not isinstance(value, (int, float)) or isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or confidence < 0.8 or value <= 0 ): rejected.append({"index": index, "reason": "not_unambiguous_overall_arrow"}) continue candidates[axis].append({ "value_mm": float(value) * unit_scale[unit], "confidence": min(1.0, float(confidence)), "source_image_ids": [source_image_id], "text": str(row.get("text") or "")[:120], "location": str(row.get("location") or "")[:200], }) ledger = {"conflicts": [], "rejected": rejected} for axis, rows in candidates.items(): if not rows: continue values = [row["value_mm"] for row in rows] tolerance = max(1.0, 0.005 * max(values)) if max(values) - min(values) > tolerance: ledger["conflicts"].append({ "axis": axis, "values_mm": values, "source_image_ids": sorted({source for row in rows for source in row["source_image_ids"]}), }) continue best = max(rows, key=lambda row: row["confidence"]) ledger[axis] = {**best, "value_mm": sum(values) / len(values), "tolerance_mm": tolerance} return ledger def apply_graph_patch(graph: dict, patches: list[dict]) -> tuple[dict, list[dict]]: """Apply a bounded RFC6902 subset and return an immutable audit trail.""" if not isinstance(patches, list) or not 1 <= len(patches) <= 64: raise GraphError("图谱局部修改必须包含1到64条操作") result = copy.deepcopy(graph) allowed_roots = { "observations", "parameters", "envelope_mm", "parts", "instances", "connections", "uncertainties", "constraint_solver", "contact_derivation", "placement_solver", "assemblies", "assembly_connections", } audit = [] def list_position(rows, token, path): """Resolve a JSON array index or a stable object ``id``. Model-authored repairs naturally refer to ``/assemblies/drawer-1`` or ``/instances/left-side``. Requiring a volatile numeric array index made otherwise valid repairs fail or target the wrong object after an insertion. Stable IDs are therefore part of the patch contract. """ if isinstance(token, str) and re.fullmatch(r"0|[1-9]\d*", token): position = int(token) if not 0 <= position < len(rows): raise GraphError(f"修改路径不存在: {path}") return position matches = [ index for index, row in enumerate(rows) if isinstance(row, dict) and str(row.get("id") or "") == str(token) ] if len(matches) != 1: raise GraphError(f"修改路径中的稳定ID不存在或不唯一: {path}") return matches[0] def tokens(path): if not isinstance(path, str) or not path.startswith("/"): raise GraphError("局部修改路径必须是JSON Pointer") values = [token.replace("~1", "/").replace("~0", "~") for token in path[1:].split("/")] if not values or values[0] not in allowed_roots or any(token in {"__class__", "__dict__"} for token in values): raise GraphError(f"禁止修改图谱路径: {path}") return values for index, patch in enumerate(patches): if not isinstance(patch, dict) or patch.get("op") not in {"add", "replace", "remove"}: raise GraphError(f"第{index + 1}条修改操作无效") path = patch.get("path") parts = tokens(path) parent = result for token in parts[:-1]: try: parent = parent[list_position(parent, token, path)] if isinstance(parent, list) else parent[token] except (KeyError, IndexError, TypeError, ValueError) as exc: raise GraphError(f"修改路径不存在: {path}") from exc leaf = parts[-1] old = None try: if isinstance(parent, list): if leaf == "-": if patch["op"] != "add": raise GraphError(f"只有add允许使用数组末尾路径: {path}") parent.append(copy.deepcopy(patch.get("value"))) else: position = list_position(parent, leaf, path) if patch["op"] == "add": parent.insert(position, copy.deepcopy(patch.get("value"))) else: old = copy.deepcopy(parent[position]) if patch["op"] == "replace": parent[position] = copy.deepcopy(patch.get("value")) else: parent.pop(position) else: if patch["op"] == "add": old = copy.deepcopy(parent.get(leaf)) parent[leaf] = copy.deepcopy(patch.get("value")) else: if leaf not in parent: raise KeyError(leaf) old = copy.deepcopy(parent[leaf]) if patch["op"] == "replace": parent[leaf] = copy.deepcopy(patch.get("value")) else: del parent[leaf] except (KeyError, IndexError, TypeError, ValueError) as exc: if isinstance(exc, GraphError): raise raise GraphError(f"无法执行修改路径: {path}") from exc audit.append({"index": index + 1, "op": patch["op"], "path": path, "old": old, "new": copy.deepcopy(patch.get("value")) if patch["op"] != "remove" else None}) return result, audit class ParameterResolver: def __init__(self, parameters: dict, observations: dict): self.parameters = parameters self.observations = observations self.resolved = {} self.visiting = set() @staticmethod def finite(value): if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): raise GraphError("必须为有限数值") return float(value) def parameter(self, key): if key in self.resolved: return self.resolved[key] if key in self.visiting: raise GraphError(f"参数依赖成环: {key}") if key not in self.parameters: raise GraphError(f"参数不存在: {key}") self.visiting.add(key) try: spec = self.parameters[key] kind = spec.get("kind") dimension = 1 if kind == "length" else 0 if kind not in {"length", "count", "scalar"}: raise GraphError(f"参数 {key} 缺少明确类型") origin = spec.get("origin") refs = spec.get("evidence_ids") or [] if origin == "derived": value, actual_dimension, confidence, estimated = self.expression(spec.get("expression")) if actual_dimension != dimension: raise GraphError(f"参数 {key} 派生式量纲错误") elif origin in {"observed", "estimated", "user"}: if origin in {"observed", "user"} and ( not refs or any(ref not in self.observations for ref in refs) ): raise GraphError(f"参数 {key} 缺少可回溯证据") if origin == "observed" and any(self.observations[ref].get("source_type") != "image" for ref in refs): raise GraphError(f"参数 {key} 图像事实引用了非原图证据") if origin == "user" and any(self.observations[ref].get("source_type") != "user" for ref in refs): raise GraphError(f"参数 {key} 不能把AI判断写成用户确认") value = self.finite(spec.get("value")) unit = spec.get("unit") if dimension: if unit not in {"mm", "cm", "m"}: raise GraphError(f"参数 {key} 长度单位不明确") value *= {"mm": 1, "cm": 10, "m": 1000}[unit] elif unit not in {None, "", "1", "件"}: raise GraphError(f"参数 {key} 无量纲参数不应带长度单位") confidence = min([self.finite(spec.get("confidence", 0))] + [self.finite(self.observations[ref].get("confidence", 0)) for ref in refs]) estimated = origin == "estimated" if estimated: confidence = min(confidence, 0.55) else: raise GraphError(f"参数 {key} 缺少来源类型") if kind == "count" and (value < 0 or value != int(value)): raise GraphError(f"参数 {key} 数量必须是非负整数") if not 0 <= confidence <= 1: raise GraphError(f"参数 {key} 置信度超界") result = (value, dimension, confidence, estimated) self.resolved[key] = result return result finally: self.visiting.remove(key) def expression(self, text, *, expected_dimension=None): if not isinstance(text, str) or len(text) > 500: raise GraphError("表达式必须是长度受限的参数算式") try: tree = ast.parse(text, mode="eval") except (SyntaxError, ValueError) as exc: raise GraphError("参数表达式语法错误") from exc if sum(1 for _ in ast.walk(tree)) > 100: raise GraphError("参数表达式过于复杂") def visit(node): if isinstance(node, ast.Constant): return self.finite(node.value), 0, 1.0, False, True if isinstance(node, ast.Name): return (*self.parameter(node.id), False) if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)): value, dimension, confidence, estimated, literal = visit(node.operand) return (-value if isinstance(node.op, ast.USub) else value), dimension, confidence, estimated, literal if not isinstance(node, ast.BinOp) or not isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)): raise GraphError("只允许参数、数字和加减乘除,不执行函数或代码") a, da, ca, ea, la = visit(node.left) b, db, cb, eb, lb = visit(node.right) if isinstance(node.op, (ast.Add, ast.Sub)): # Geometry fields use millimetres as their contextual unit. A # literal offset in ``width-30`` therefore means 30 mm, while # the literal in ``2*thickness`` remains dimensionless. Derived # parameter definitions stay strict unless their caller supplies # an expected length context. if expected_dimension == 1 and da != db: if la and da == 0 and db == 1: da = 1 elif lb and db == 0 and da == 1: db = 1 if da != db: raise GraphError("相加/相减的量纲不同") value, dimension = (a + b if isinstance(node.op, ast.Add) else a - b), da elif isinstance(node.op, ast.Mult): value, dimension = a * b, da + db else: if b == 0: raise GraphError("参数表达式除以零") value, dimension = a / b, da - db self.finite(value) return value, dimension, min(ca, cb), ea or eb, la and lb value, dimension, confidence, estimated, _literal = visit(tree.body) return value, dimension, confidence, estimated def length(self, expression): # Numeric geometry literals are explicitly millimetres under this schema. if isinstance(expression, (int, float)) and not isinstance(expression, bool): return self.finite(expression) if isinstance(expression, str) and re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)", expression.strip()): return self.finite(float(expression)) value, dimension, _, _ = self.expression(expression, expected_dimension=1) if dimension != 1: raise GraphError("尺寸/位置表达式的结果必须是长度") return value def _solve_orthogonal_contact_normals(instances: dict, connections: list[dict]) -> tuple[list[dict], list[dict]]: """Solve only the normal-axis equation declared by each face mate. The AI owns topology (which faces mate); the solver owns arithmetic. It never guesses tangential alignment or connection type. Every movement is returned for audit, and contradictory cycles remain blocking errors. """ adjacency = {axis: {key: [] for key in instances} for axis in range(3)} errors = [] for index, edge in enumerate(connections): if edge.get("kind") not in {"fixed", "support"}: continue a, b = instances.get(edge.get("a")), instances.get(edge.get("b")) fa, fb = edge.get("face_a"), edge.get("face_b") if not a or not b or fa not in {"x-", "x+", "y-", "y+", "z-", "z+"} or fb not in {"x-", "x+", "y-", "y+", "z-", "z+"}: continue if fa[0] != fb[0] or fa[1] == fb[1]: continue axis = "xyz".index(fa[0]) offset_a = a["size_mm"][axis] if fa[1] == "+" else 0.0 offset_b = b["size_mm"][axis] if fb[1] == "+" else 0.0 delta = offset_a - offset_b # position_b = position_a + delta adjacency[axis][a["id"]].append((b["id"], delta, index)) adjacency[axis][b["id"]].append((a["id"], -delta, index)) adjustments = [] for axis, graph in adjacency.items(): unseen = {key for key, links in graph.items() if links} while unseen: root = next(iter(unseen)) potential = {root: 0.0} pending = [root] component = set() conflict = False while pending: node = pending.pop() component.add(node) for other, delta, edge_index in graph[node]: proposed = potential[node] + delta if other in potential and abs(potential[other] - proposed) > 0.01: errors.append({ "code": "constraint_cycle_conflict", "path": f"/connections/{edge_index}", "detail": f"{('xyz'[axis]).upper()}轴接触约束闭环互相矛盾{abs(potential[other]-proposed):.3f}mm", "repair_owner": "ai", "axis": "xyz"[axis], "instance_ids": sorted(component | {other}), }) conflict = True elif other not in potential: potential[other] = proposed pending.append(other) unseen -= component if conflict: continue grounded = [key for key in component if instances[key].get("grounded") is True] if len(grounded) > 1: translations = [instances[key]["position_mm"][axis] - potential[key] for key in grounded] if max(translations) - min(translations) > 0.01: errors.append({ "code": "grounded_constraint_conflict", "path": "/instances", "detail": f"多个固定实例在{('xyz'[axis]).upper()}轴与接触约束冲突", "repair_owner": "ai", "axis": "xyz"[axis], "instance_ids": sorted(grounded), }) continue anchor = grounded[0] if grounded else min(component, key=lambda key: instances[key]["position_mm"][axis]) translation = instances[anchor]["position_mm"][axis] - potential[anchor] for key in component: old = instances[key]["position_mm"][axis] new = potential[key] + translation if abs(old - new) > 0.01: instances[key]["position_mm"][axis] = new adjustments.append({ "instance_id": key, "axis": "xyz"[axis], "from_mm": old, "to_mm": new, "reason": "orthogonal_contact_normal_constraint", }) return adjustments, errors def _aabb_contact(a: dict, b: dict, *, tolerance_mm: float = 0.6) -> dict | None: """Return one real boundary contact with positive tangential area.""" matches = [] for axis in range(3): a_min = a["position_mm"][axis] a_max = a_min + a["size_mm"][axis] b_min = b["position_mm"][axis] b_max = b_min + b["size_mm"][axis] if abs(a_max - b_min) <= tolerance_mm: matches.append((axis, "+", "-", abs(a_max - b_min))) if abs(b_max - a_min) <= tolerance_mm: matches.append((axis, "-", "+", abs(b_max - a_min))) for axis, sign_a, sign_b, gap in matches: overlaps = [ min(a["position_mm"][other] + a["size_mm"][other], b["position_mm"][other] + b["size_mm"][other]) - max(a["position_mm"][other], b["position_mm"][other]) for other in range(3) if other != axis ] if min(overlaps) <= 0.01: continue return { "a": a["id"], "b": b["id"], "face_a": f"{'xyz'[axis]}{sign_a}", "face_b": f"{'xyz'[axis]}{sign_b}", "gap_mm": gap, "overlap_mm": overlaps, "source": "aabb_positive_area_contact", } return None def _aabb_interference(a: dict, b: dict, *, tolerance_mm: float = 0.6) -> dict | None: """Return a real solid-volume collision, ignoring face contact tolerance. The graph compiler currently models every instance as a closed solid box. Two boxes may share a face, edge or point, but they may not occupy the same volume. This catches shelves cutting through side panels and drawer walls accidentally placed on top of one another. """ overlaps = [ min(a["position_mm"][axis] + a["size_mm"][axis], b["position_mm"][axis] + b["size_mm"][axis]) - max(a["position_mm"][axis], b["position_mm"][axis]) for axis in range(3) ] if min(overlaps) <= tolerance_mm: return None return { "a": a["id"], "b": b["id"], "overlap_mm": overlaps, "overlap_volume_mm3": overlaps[0] * overlaps[1] * overlaps[2], "source": "aabb_positive_volume_intersection", } def _derive_aabb_contacts(instances: dict, *, tolerance_mm: float = 0.6) -> list[dict]: """Derive real positive-area contacts inside each declared assembly. This replaces an unreliable language-model task: the AI declares which physical instance belongs to which assembly, while geometry determines what actually touches. Cross-assembly motion (drawer/door to carcass) remains explicit because a clearance joint should not geometrically touch. """ contacts = [] rows = list(instances.values()) for left_index, a in enumerate(rows): for b in rows[left_index + 1:]: if a.get("assembly_id") != b.get("assembly_id"): continue contact = _aabb_contact(a, b, tolerance_mm=tolerance_mm) if contact: contact_axis = contact["face_a"][0] a_broad_face = a.get("thickness_axis") == contact_axis b_broad_face = b.get("thickness_axis") == contact_axis interface_type = ( "face_to_face" if a_broad_face and b_broad_face else "edge_to_face" if a_broad_face or b_broad_face else "edge_to_edge" ) contacts.append({ **contact, "id": f"derived:{a['id']}:{b['id']}:{contact['face_a'][0]}", "kind": "geometric_contact", "assembly_id": a.get("assembly_id"), "interface_type": interface_type, "broad_face_instance_id": ( a["id"] if a_broad_face and not b_broad_face else b["id"] if b_broad_face and not a_broad_face else None ), "edge_instance_id": ( b["id"] if a_broad_face and not b_broad_face else a["id"] if b_broad_face and not a_broad_face else None ), }) return contacts def _solve_anchored_axis_placements( instance_specs: dict, *, resolver: ParameterResolver, envelope_mm: list[float], assembly_bounds: dict | None = None, ) -> tuple[dict, list[dict]]: """Resolve declared design intent into coordinates. The model names an anchor relationship (for example, the right side's maximum X is aligned to the product envelope's maximum X). It never gets to guess the resulting coordinate. References form a dependency graph; cycles and missing axes are rejected instead of being numerically nudged. """ resolved = {} audit = [] visiting = set() anchors = {"min": 0.0, "center": 0.5, "max": 1.0} def length(value): return resolver.length(0 if value is None else value) def solve(instance_id): if instance_id in resolved: return resolved[instance_id] if instance_id in visiting: raise GraphError(f"实例定位依赖成环: {instance_id}") if instance_id not in instance_specs: raise GraphError(f"定位引用了不存在的实例: {instance_id}") visiting.add(instance_id) try: spec = instance_specs[instance_id] placement = spec.get("placement") if not isinstance(placement, dict) or set(placement) != {"x", "y", "z"}: raise GraphError(f"实例 {instance_id} 必须分别声明X/Y/Z三轴定位约束") position = [] for axis, axis_name in enumerate("xyz"): rule = placement[axis_name] if not isinstance(rule, dict): raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴定位无效") mode = rule.get("mode") if mode == "absolute": coordinate = length(rule.get("value_mm")) target = "absolute" target_kind = "absolute" elif mode == "align": self_anchor = rule.get("self_anchor") target_anchor = rule.get("target_anchor") if self_anchor not in anchors or target_anchor not in anchors: raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴锚点必须为min/center/max") target_id = rule.get("target") if target_id == "envelope": target_position, target_size = 0.0, envelope_mm[axis] target_kind = "envelope" elif target_id in {"assembly", spec.get("assembly_id")}: # A model naturally refers to its declared assembly by # stable ID (for example ``carcass`` or ``drawer-1``). # Treat only the instance's *own* assembly ID as the # explicit ``assembly`` alias. Other assembly IDs do # not silently become cross-assembly coordinates. bounds = (assembly_bounds or {}).get(spec.get("assembly_id")) if not bounds: raise GraphError(f"实例 {instance_id} 引用了没有有效边界的所属总成") target_position = bounds[axis][0] target_size = bounds[axis][1] - bounds[axis][0] target_kind = "assembly" else: target_instance = solve(target_id) target_position = target_instance["position_mm"][axis] target_size = target_instance["size_mm"][axis] target_kind = "instance" coordinate = ( target_position + anchors[target_anchor] * target_size - anchors[self_anchor] * spec["size_mm"][axis] + length(rule.get("offset_mm")) ) target = target_id else: raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴只支持absolute/align定位") if not math.isfinite(coordinate): raise GraphError(f"实例 {instance_id} 的{axis_name.upper()}轴定位不是有限数") position.append(coordinate) audit.append({ "instance_id": instance_id, "axis": axis_name, "coordinate_mm": coordinate, "mode": mode, "target": target, "target_kind": target_kind, "source": "declared_anchor_constraint", }) resolved[instance_id] = {**spec, "position_mm": position} return resolved[instance_id] finally: visiting.remove(instance_id) for key in instance_specs: solve(key) return resolved, audit def compile_graph( graph: dict, *, source_image_ids: set | None = None, user_evidence: dict | None = None, dimension_ledger: dict | None = None, topology_contract: dict | None = None, ) -> dict: """Resolve a graph without repairing it by category-specific heuristics.""" issues = [] def issue(code, path, detail, **context): issues.append({"code": code, "path": path, "detail": detail, "repair_owner": "ai", **context}) if graph.get("schema_version") != SCHEMA_VERSION: issue("schema_version", "/schema_version", "结构图谱版本不匹配") observations = {} source_image_ids = source_image_ids or set() user_evidence = user_evidence or {} for index, row in enumerate(graph.get("observations") or []): key = row.get("id") if not key or key in observations or row.get("source_type") not in {"image", "user"}: issue("evidence_identity", f"/observations/{index}", "证据ID缺失、重复或来源不明确") continue if row.get("source_type") == "image" and row.get("image_id") not in source_image_ids: issue("evidence_image", f"/observations/{index}", "图像证据未引用本轮实际提供的原图") continue if row.get("source_type") == "user" and user_evidence.get(key) != row: issue("evidence_user", f"/observations/{index}", "用户确认必须来自会话事实库,不能由模型自行生成") continue observations[key] = row resolver = ParameterResolver(graph.get("parameters") or {}, observations) for key in resolver.parameters: try: resolver.parameter(key) except (GraphError, TypeError, AttributeError) as exc: issue("parameter", f"/parameters/{key}", str(exc)) envelope = [] try: envelope = [resolver.length(value) for value in graph["envelope_mm"]] if len(envelope) != 3 or min(envelope) <= 0: raise GraphError("整体包围尺寸必须有三个正值") except (GraphError, KeyError, TypeError) as exc: issue("envelope", "/envelope_mm", str(exc)) parts = {} panel_roles = { "top", "bottom", "side", "shelf", "door", "back", "drawer_front", "drawer_left", "drawer_right", "drawer_back", "drawer_bottom", "drawer_support", "divider", } allowed_roles = panel_roles | {"leg", "rail", "stile", "frame_member", "hardware", "other"} allowed_material_families = { "engineered_panel", "solid_wood", "metal", "plastic", "glass", "other", } for index, part in enumerate(graph.get("parts") or []): key = part.get("id") if not key or key in parts: issue("part_identity", f"/parts/{index}", "零件ID缺失或重复") continue try: size = [resolver.length(value) for value in part["size_mm"]] if len(size) != 3 or min(size) <= 0: raise GraphError("零件三轴尺寸必须大于0") if part.get("shape") != "box": raise GraphError("当前编译器不支持此几何,不得默默替换成长方体") role = part.get("functional_role") if role not in allowed_roles: raise GraphError("零件必须声明稳定的functional_role,不能由中文名称反推结构") material_family = part.get("material_family") if material_family not in {None, "", *allowed_material_families}: raise GraphError("零件material_family不在受支持范围") thickness_axis = part.get("thickness_axis") if role in panel_roles: if thickness_axis not in {"x", "y", "z"}: raise GraphError("板件必须明确本地厚度轴thickness_axis") thickness_index = "xyz".index(thickness_axis) if size[thickness_index] > min(size) + 0.6: raise GraphError("声明的厚度轴不是此板件最薄轴") elif thickness_axis not in {None, "", "x", "y", "z"}: raise GraphError("非板件厚度轴只能为空或x/y/z") parts[key] = {**part, "size_mm": size} except (GraphError, KeyError, TypeError) as exc: issue("part_geometry", f"/parts/{index}", str(exc)) assemblies = {} assembly_bounds = {} if graph.get("assemblies") is not None: for index, assembly in enumerate(graph.get("assemblies") or []): path = f"/assemblies/{index}" key = assembly.get("id") if isinstance(assembly, dict) else None if not key or key in assemblies: issue("assembly_identity", path, "总成ID缺失或重复") continue if assembly.get("role") not in {"carcass", "drawer", "door", "module", "frame", "hardware_group", "other"}: issue("assembly_role", path, "总成必须声明可审计的结构角色") continue refs = assembly.get("evidence_ids") or [] if not refs or any(ref not in observations for ref in refs): issue("assembly_evidence", path, "总成划分必须引用本轮原图或真实用户证据") continue try: raw_bounds = assembly["bounds_mm"] if not isinstance(raw_bounds, dict) or set(raw_bounds) != {"x", "y", "z"}: raise GraphError("总成边界必须分别声明X/Y/Z最小与最大值") bounds = [] for axis, axis_name in enumerate("xyz"): values = [resolver.length(value) for value in raw_bounds[axis_name]] if len(values) != 2 or values[1] <= values[0]: raise GraphError(f"总成{axis_name.upper()}轴上限必须大于下限") if envelope and (values[0] < -0.5 or values[1] > envelope[axis] + 0.5): raise GraphError(f"总成{axis_name.upper()}轴边界超出产品外形") bounds.append(values) assembly_bounds[key] = bounds assemblies[key] = {**assembly, "bounds_mm": { axis_name: bounds[axis] for axis, axis_name in enumerate("xyz") }} except (GraphError, KeyError, TypeError) as exc: issue("assembly_bounds", path, str(exc)) instances = {} instance_specs = {} for index, inst in enumerate(graph.get("instances") or []): key = inst.get("id") try: if not key or key in instances or key in instance_specs or inst.get("part_id") not in parts or not inst.get("assembly_id"): raise GraphError("实例ID重复、零件不存在或未声明所属总成") # Axis order is explicit. No board-name/longest-edge remapping. if inst.get("axis_order", [0, 1, 2]) not in ([0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]): raise GraphError("只支持明确的正交轴映射,任意角度需后续几何编译器") size = [parts[inst["part_id"]]["size_mm"][a] for a in inst.get("axis_order", [0, 1, 2])] local_thickness_axis = parts[inst["part_id"]].get("thickness_axis") product_thickness_axis = None if local_thickness_axis in {"x", "y", "z"}: product_thickness_axis = "xyz"[ inst.get("axis_order", [0, 1, 2]).index("xyz".index(local_thickness_axis)) ] inherited = { "functional_role": parts[inst["part_id"]].get("functional_role"), "thickness_axis": product_thickness_axis, "material_family": parts[inst["part_id"]].get("material_family"), } if graph.get("placement_solver") == "anchored_axes_v1": instance_specs[key] = {**inst, **inherited, "size_mm": size} else: origin = [resolver.length(value) for value in inst["position_mm"]] if len(origin) != 3: raise GraphError("实例位置必须有三轴") instances[key] = {**inst, **inherited, "position_mm": origin, "size_mm": size} except (GraphError, KeyError, TypeError) as exc: issue("instance_geometry", f"/instances/{index}", str(exc)) placement_audit = [] if graph.get("placement_solver") == "anchored_axes_v1" and envelope: try: instances, placement_audit = _solve_anchored_axis_placements( instance_specs, resolver=resolver, envelope_mm=envelope, assembly_bounds=assembly_bounds, ) except (GraphError, KeyError, TypeError) as exc: issue("placement_constraint", "/instances", str(exc)) if graph.get("assemblies") is not None: for key, inst in instances.items(): if inst.get("assembly_id") not in assemblies: issue("instance_assembly", f"/instances/{key}/assembly_id", "实例引用了未声明的总成") continue bounds = assembly_bounds[inst["assembly_id"]] outside_axes = [] for axis, axis_name in enumerate("xyz"): low = inst["position_mm"][axis] high = low + inst["size_mm"][axis] if low < bounds[axis][0] - 0.5 or high > bounds[axis][1] + 0.5: outside_axes.append({ "axis": axis_name, "instance_range_mm": [low, high], "assembly_range_mm": bounds[axis], }) if outside_axes: issue( "outside_assembly_bounds", f"/instances/{key}", f"部件超出所属总成{inst['assembly_id']}的局部边界;不能借用整件产品坐标掩盖模块错位", assembly_id=inst["assembly_id"], axes=outside_axes, ) used = Counter(inst.get("assembly_id") for inst in instances.values()) for key in assemblies: if not used[key]: issue("empty_assembly", f"/assemblies/{key}", "空总成不能作为装配关系的虚拟占位") continue rows = [inst for inst in instances.values() if inst.get("assembly_id") == key] if not rows: continue actual = [ [min(row["position_mm"][axis] for row in rows), max(row["position_mm"][axis] + row["size_mm"][axis] for row in rows)] for axis in range(3) ] mismatch = [] for axis, axis_name in enumerate("xyz"): declared = assembly_bounds[key][axis] if abs(actual[axis][0] - declared[0]) > 0.6 or abs(actual[axis][1] - declared[1]) > 0.6: mismatch.append({ "axis": axis_name, "declared_mm": declared, "actual_member_union_mm": actual[axis], }) if mismatch: issue( "assembly_bounds_not_tight", f"/assemblies/{key}/bounds_mm", "总成边界必须等于所属实体的实际包围盒,不能用过大的虚拟空间掩盖错位或漏件", assembly_id=key, axes=mismatch, ) solver_adjustments = [] if graph.get("constraint_solver") == "orthogonal_contacts_v1": solver_adjustments, solver_errors = _solve_orthogonal_contact_normals( instances, list(graph.get("connections") or []) ) issues.extend(solver_errors) quantities = Counter(inst["part_id"] for inst in instances.values()) for key, part in parts.items(): if not quantities[key]: issue("uninstantiated_part", f"/parts/{key}", "零件没有物理实例,不能计入BOM") expected = part.get("observed_quantity") if expected is not None and expected != quantities[key]: issue("quantity_conflict", f"/parts/{key}", f"图像数量{expected}与实例数量{quantities[key]}不符") role_quantities = Counter( str(inst.get("functional_role") or "") for inst in instances.values() if str(inst.get("functional_role") or "") ) role_contract = (topology_contract or {}).get("role_counts") or {} if isinstance(role_contract, dict): for role, expected in role_contract.items(): if ( not isinstance(role, str) or isinstance(expected, bool) or not isinstance(expected, int) or expected < 0 ): issue("source_topology_contract", "/topology_contract/role_counts", "原图拓扑契约数量格式无效") continue actual = role_quantities.get(role, 0) if actual != expected: issue( "source_topology_count_conflict", f"/topology_contract/role_counts/{role}", f"独立多视图证据计数{role}={expected},结构图谱生成{actual};禁止静默漏件或多件", role=role, expected=expected, actual=actual, evidence=((topology_contract or {}).get("evidence") or {}).get(role) or [], ) seen_connections = set() neighbours = {key: set() for key in instances} for index, edge in enumerate(graph.get("connections") or []): path = f"/connections/{index}" key = edge.get("id") if not key or key in seen_connections: issue("connection_identity", path, "连接ID缺失或重复") continue seen_connections.add(key) a, b = instances.get(edge.get("a")), instances.get(edge.get("b")) if not a or not b or a["id"] == b["id"]: issue("connection_target", path, "连接两端必须是不同且存在的物理实例") continue if edge.get("kind") not in {"fixed", "sliding", "hinged", "support"}: issue("connection_kind", path, "连接方式不明确") continue neighbours[a["id"]].add(b["id"]) neighbours[b["id"]].add(a["id"]) if edge["kind"] not in {"fixed", "support"}: continue # Motion/hardware templates require a later dedicated validator. faces = [edge.get("face_a"), edge.get("face_b")] if any(face not in {"x-", "x+", "y-", "y+", "z-", "z+"} for face in faces): issue("connection_face", path, "固定连接必须指定两个接触面") continue if faces[0][0] != faces[1][0] or faces[0][1] == faces[1][1]: issue("connection_face", path, "接触面法向必须相反") continue axis = "xyz".index(faces[0][0]) plane_a = a["position_mm"][axis] + (a["size_mm"][axis] if faces[0][1] == "+" else 0) plane_b = b["position_mm"][axis] + (b["size_mm"][axis] if faces[1][1] == "+" else 0) tolerance = float(edge.get("tolerance_mm", 0.5)) if not math.isfinite(tolerance) or not 0 <= tolerance <= 2: issue("connection_tolerance", path, "自动几何接触检查公差必须为0–2mm,不能用大公差掩盖错位") continue overlap = [min(a["position_mm"][d] + a["size_mm"][d], b["position_mm"][d] + b["size_mm"][d]) - max(a["position_mm"][d], b["position_mm"][d]) for d in range(3) if d != axis] if abs(plane_a - plane_b) > tolerance or min(overlap) <= 0: required_b_origin = plane_a - (b["size_mm"][axis] if faces[1][1] == "+" else 0.0) repair_hint = ( "两平面已重合但切向没有正面积交叠;优先回看板件是端面对侧面还是上下叠放,并修改face_a/face_b,不能移动到产品外" if abs(plane_a - plane_b) <= tolerance and min(overlap) <= 0 else "接触法向坐标错误;核对连接面后再修改实例位置或约束" ) issue( "connection_gap", path, f"{a['id']}的{faces[0]}与{b['id']}的{faces[1]}未接触;法向间距{abs(plane_a-plane_b):.3f}mm,切向重叠{overlap}mm", connection_id=key, a=a["id"], b=b["id"], face_a=faces[0], face_b=faces[1], axis="xyz"[axis], plane_a_mm=plane_a, plane_b_mm=plane_b, current_b_origin_mm=b["position_mm"][axis], required_b_origin_mm=required_b_origin, suggested_repair=repair_hint, ) derived_contacts = [] if graph.get("contact_derivation") == "aabb_within_assembly_v1": derived_contacts = _derive_aabb_contacts(instances) for edge in derived_contacts: neighbours[edge["a"]].add(edge["b"]) neighbours[edge["b"]].add(edge["a"]) assembly_connection_audit = [] if assemblies: members = { assembly_id: [inst for inst in instances.values() if inst.get("assembly_id") == assembly_id] for assembly_id in assemblies } # A relation between assemblies cannot hide loose parts inside either # assembly. Every multi-part assembly must first be a real contact # component on its own. internal_neighbours = {key: set() for key in instances} for edge in derived_contacts: internal_neighbours[edge["a"]].add(edge["b"]) internal_neighbours[edge["b"]].add(edge["a"]) for assembly_id, rows in members.items(): if len(rows) <= 1: continue member_ids = {row["id"] for row in rows} reached, pending = set(), [rows[0]["id"]] while pending: current = pending.pop() if current in reached: continue reached.add(current) pending.extend((internal_neighbours[current] & member_ids) - reached) for instance_id in sorted(member_ids - reached): issue( "assembly_internal_disconnected", f"/instances/{instance_id}", f"部件在总成{assembly_id}内没有真实接触路径,总成运动副不能掩盖散落零件", assembly_id=assembly_id, ) seen_assembly_connections = set() assembly_neighbours = {key: set() for key in assemblies} for index, edge in enumerate(graph.get("assembly_connections") or []): path = f"/assembly_connections/{index}" key = edge.get("id") if isinstance(edge, dict) else None if not key or key in seen_assembly_connections: issue("assembly_connection_identity", path, "总成连接ID缺失或重复") continue seen_assembly_connections.add(key) a_id, b_id = edge.get("a"), edge.get("b") instance_endpoints = { endpoint: instances[endpoint].get("assembly_id") for endpoint in (a_id, b_id) if endpoint in instances } if instance_endpoints: moving_hint = None if a_id in instances: current_assembly = instances[a_id].get("assembly_id") moving_hint = { "instance_id": a_id, "current_assembly_id": current_assembly, "required_action": ( "若该实例是运动件,为它建立边界等于实体包围盒的独立总成," "把实例assembly_id迁入新总成,并让连接a引用新总成ID" ), } issue( "assembly_connection_uses_instance", path, "总成连接错误地引用了零件实例;运动副端点必须是总成ID", instance_endpoint_to_current_assembly=instance_endpoints, moving_endpoint_repair=moving_hint, stationary_endpoint_repair=( f"连接b应引用固定端实例所属总成{instances[b_id].get('assembly_id')}" if b_id in instances else None ), ) continue if a_id not in assemblies or b_id not in assemblies or a_id == b_id: issue("assembly_connection_target", path, "总成连接两端必须是不同且已声明的总成") continue if not members.get(a_id) or not members.get(b_id): issue("assembly_connection_target", path, "总成连接不能引用空总成") continue kind = edge.get("kind") if kind not in {"fixed", "sliding", "hinged"}: issue("assembly_connection_kind", path, "总成关系只能是fixed/sliding/hinged") continue refs = edge.get("evidence_ids") or [] if not refs or any(ref not in observations for ref in refs): issue("assembly_connection_evidence", path, "总成关系必须引用真实证据") continue contact = None if kind == "fixed": for a_inst in members[a_id]: for b_inst in members[b_id]: contact = _aabb_contact(a_inst, b_inst) if contact: break if contact: break if not contact: issue( "fixed_assemblies_not_contacting", path, f"固定总成{a_id}与{b_id}之间没有任何真实正面积接触", ) continue else: if edge.get("axis") not in {"x", "y", "z"}: issue("assembly_motion_axis", path, "滑动/铰接总成必须明确声明运动轴") continue if kind == "sliding": limits = edge.get("travel_limits_mm") try: values = [resolver.length(value) for value in limits] if len(values) != 2 or values[1] <= values[0]: raise GraphError("滑动行程上限必须大于下限") except (GraphError, TypeError) as exc: issue("assembly_motion_limits", path, f"滑动总成需要两个有效行程限位: {exc}") continue else: limits = edge.get("angle_limits_deg") if ( not isinstance(limits, list) or len(limits) != 2 or any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in limits) or limits[1] <= limits[0] ): issue("assembly_motion_limits", path, "铰接总成需要两个有效角度限位") continue if kind == "sliding": motion_axis = "xyz".index(edge["axis"]) moving_bounds = assembly_bounds[a_id] guide_bounds = assembly_bounds[b_id] clearance_errors = [] for axis, axis_name in enumerate("xyz"): if axis == motion_axis: continue if ( moving_bounds[axis][0] < guide_bounds[axis][0] - 0.5 or moving_bounds[axis][1] > guide_bounds[axis][1] + 0.5 ): clearance_errors.append({ "axis": axis_name, "moving_range_mm": moving_bounds[axis], "guide_range_mm": guide_bounds[axis], }) if clearance_errors: issue( "sliding_assembly_outside_guide", path, f"滑动总成{a_id}在非运动轴上超出导向总成{b_id},无法形成科学的抽拉关系", axes=clearance_errors, ) continue a_rep, b_rep = members[a_id][0], members[b_id][0] neighbours[a_rep["id"]].add(b_rep["id"]) neighbours[b_rep["id"]].add(a_rep["id"]) assembly_neighbours[a_id].add(b_id) assembly_neighbours[b_id].add(a_id) assembly_connection_audit.append({ "id": key, "kind": kind, "a": a_id, "b": b_id, "axis": edge.get("axis"), "verified_contact": contact, }) if len(assemblies) > 1: reached, pending = set(), [next(iter(assemblies))] while pending: key = pending.pop() if key not in reached: reached.add(key) pending.extend(assembly_neighbours[key] - reached) for key in assemblies.keys() - reached: issue("disconnected_assembly", f"/assemblies/{key}", "此总成没有连接到产品的装配关系图") if len(instances) > 1: reached, pending = set(), [next(iter(instances))] while pending: key = pending.pop() if key not in reached: reached.add(key) pending.extend(neighbours[key] - reached) for key in instances.keys() - reached: issue("disconnected_instance", f"/instances/{key}", "此部件没有可追溯到产品整体的连接路径;不能靠省略连接关系跳过几何检查") seen_boxes = {} collision_pairs = [] instance_rows = list(instances.values()) for left_index, a in enumerate(instance_rows): for b in instance_rows[left_index + 1:]: collision = _aabb_interference(a, b) if collision: collision_pairs.append(collision) issue( "solid_interference", f"/instances/{b['id']}", f"实体{a['id']}与{b['id']}发生正体积穿插,不能作为可制造装配", **collision, ) for key, inst in instances.items(): signature = tuple(round(value, 6) for value in inst["position_mm"] + inst["size_mm"]) if signature in seen_boxes: issue("duplicate_solid", f"/instances/{key}", f"与{seen_boxes[signature]}占据同一实体,禁止重复计费") seen_boxes[signature] = key if envelope: for key, inst in instances.items(): if any(inst["position_mm"][axis] < -0.5 or inst["position_mm"][axis] + inst["size_mm"][axis] > envelope[axis] + 0.5 for axis in range(3)): issue("outside_envelope", f"/instances/{key}", "部件超出产品边界;不允许自动裁小来隐藏冲突") # Directly read overall arrows are external evidence, not an AI naming # convention. Compare the compiled X/Y/Z envelope to that ledger so a # model cannot silently turn a 300 mm side-view depth into product width. # Conflicted or low-confidence labels are intentionally omitted by the # upstream ledger builder and therefore remain AI-estimated draft values. if len(envelope) == 3: for axis_index, axis in enumerate("xyz"): fact = (dimension_ledger or {}).get(axis) if not isinstance(fact, dict) or not isinstance(fact.get("value_mm"), (int, float)): continue expected = float(fact["value_mm"]) tolerance = max(1.0, float(fact.get("tolerance_mm") or 1.0)) if abs(envelope[axis_index] - expected) > tolerance: issue( "overall_dimension_conflict", f"/envelope_mm/{axis_index}", f"CAD整体{axis.upper()}轴{envelope[axis_index]:.3f}mm与原图尺寸箭头{expected:.3f}mm冲突", axis=axis, compiled_mm=envelope[axis_index], source_mm=expected, source_image_ids=list(fact.get("source_image_ids") or []), ) return { "schema_version": SCHEMA_VERSION, "graph_sha256": hashlib.sha256(json.dumps(graph, ensure_ascii=False, sort_keys=True).encode()).hexdigest(), "ok": not issues, "issues": issues, "release_status": "drawing_draft", "visual_validation_required": True, "manufacturing_validation_required": True, "parameters": {key: {"value": value, "length_exponent": dimension, "confidence": confidence, "estimated": estimated} for key, (value, dimension, confidence, estimated) in resolver.resolved.items()}, "instances": list(instances.values()), "solver_adjustments": solver_adjustments, "placement_audit": placement_audit, "derived_contacts": derived_contacts, "assemblies": list(assemblies.values()), "assembly_connection_audit": assembly_connection_audit, "collision_audit": collision_pairs, "dimension_ledger": dimension_ledger or {}, "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")} for key, part in parts.items() if quantities[key]], }