2026-09-07 21:04:02 +08:00

1336 lines
67 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.

"""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"
# New graphs use one product coordinate system end-to-end: X=width,
# Y=depth, Z=height. These templates are role semantics, not Chinese-name
# heuristics. They remove the formerly independent ``axis_order`` degree of
# freedom that let a model rotate a panel a second time after already writing
# product-oriented dimensions.
ROLE_PRODUCT_THICKNESS_AXES = {
"top": {"z"},
"bottom": {"z"},
"side": {"x"},
"shelf": {"z"},
"door": {"y"},
"back": {"y"},
"drawer_front": {"y"},
"drawer_left": {"x"},
"drawer_right": {"x"},
"drawer_back": {"y"},
"drawer_bottom": {"z"},
"drawer_support": {"z"},
# A divider can be a vertical partition or a horizontal subdivision.
# Prefer shelf/drawer_support for horizontal members, but keep both axes
# legal so the compiler remains product-independent.
"divider": {"x", "z"},
}
ROLE_AXIS_SOLVER = "role_axis_templates_v1"
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 normalize_graph_patch_payload(graph: dict, payload) -> tuple[list[dict], list[dict]]:
"""Normalize model repair output without weakening patch safety.
JSON-capable models sometimes return the requested patch array directly.
They can also replace a complete three-axis ``placement`` object even when
only one axis changed. Accept the harmless response-shape variant and
expand a well-formed complete placement into explicit changed-axis writes.
``apply_graph_patch`` still rejects broad placement writes.
"""
if isinstance(payload, list):
raw_patches = payload
response_shape = "patch_list"
elif isinstance(payload, dict):
raw_patches = payload.get("patches")
response_shape = "patch_object"
else:
raise GraphError("图谱修复响应必须是patches对象或patch数组")
if not isinstance(raw_patches, list) or not 1 <= len(raw_patches) <= 64:
raise GraphError("图谱局部修改必须包含1到64条操作")
instances = graph.get("instances") if isinstance(graph, dict) else None
if not isinstance(instances, list):
raise GraphError("当前图谱缺少instances数组,无法规范化placement修改")
def instance_for(token: str, path: str) -> dict:
if re.fullmatch(r"0|[1-9]\d*", token):
index = int(token)
if not 0 <= index < len(instances) or not isinstance(instances[index], dict):
raise GraphError(f"修改路径不存在: {path}")
return instances[index]
matches = [
row for row in instances
if isinstance(row, dict) and str(row.get("id") or "") == token
]
if len(matches) != 1:
raise GraphError(f"修改路径中的稳定ID不存在或不唯一: {path}")
return matches[0]
normalized: list[dict] = []
audit: list[dict] = [{
"kind": "response_shape",
"source": response_shape,
"normalized_to": "patches",
}]
for index, patch in enumerate(raw_patches):
if not isinstance(patch, dict):
raise GraphError(f"第{index + 1}条修改操作无效")
path = patch.get("path")
match = re.fullmatch(r"/instances/([^/]+)/placement", str(path or ""))
if patch.get("op") == "replace" and match:
value = patch.get("value")
if not isinstance(value, dict) or set(value) != set("xyz"):
raise GraphError("整段placement规范化要求value恰好包含x、y、z三轴")
instance = instance_for(match.group(1), path)
current = instance.get("placement")
if not isinstance(current, dict) or set(current) != set("xyz"):
raise GraphError("当前实例placement必须恰好包含x、y、z三轴")
expanded_paths = []
for axis in "xyz":
if value[axis] == current[axis]:
continue
axis_path = f"{path}/{axis}"
normalized.append({
"op": "replace",
"path": axis_path,
"value": copy.deepcopy(value[axis]),
})
expanded_paths.append(axis_path)
audit.append({
"kind": "placement_expansion",
"source_index": index + 1,
"source_path": path,
"expanded_paths": expanded_paths,
})
else:
normalized.append(copy.deepcopy(patch))
if not normalized:
raise GraphError("图谱修复没有产生任何有效变化")
if len(normalized) > 64:
raise GraphError("规范化后的图谱局部修改超过64条")
return normalized, audit
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", "orientation_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)
if (
len(parts) == 3 and parts[0] == "instances" and parts[2] == "placement"
and patch.get("op") in {"replace", "remove"}
):
raise GraphError(
"禁止整段覆盖实例placement;必须只修改反馈指出的x/y/z单轴,避免已正确坐标被连带污染"
)
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", "结构图谱版本不匹配")
orientation_solver = graph.get("orientation_solver")
if orientation_solver not in {None, ROLE_AXIS_SOLVER}:
issue(
"orientation_solver", "/orientation_solver",
f"朝向求解器只支持{ROLE_AXIS_SOLVER};未知模式不能用于制造图",
actual=orientation_solver, expected=ROLE_AXIS_SOLVER,
)
role_axis_mode = orientation_solver == ROLE_AXIS_SOLVER
orientation_audit = []
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;新图谱使用产品X宽/Y深/Z高坐标"
if role_axis_mode else "板件必须明确本地厚度轴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}
if role_axis_mode and role in ROLE_PRODUCT_THICKNESS_AXES:
expected_axes = sorted(ROLE_PRODUCT_THICKNESS_AXES[role])
accepted = thickness_axis in ROLE_PRODUCT_THICKNESS_AXES[role]
orientation_audit.append({
"part_id": key, "functional_role": role,
"product_thickness_axis": thickness_axis,
"expected_product_thickness_axes": expected_axes,
"accepted": accepted,
})
if not accepted:
issue(
"role_axis_template", f"/parts/{key}/thickness_axis",
"板件角色与产品坐标厚度轴矛盾;必须修正产品X/Y/Z尺寸和厚度轴,禁止通过实例二次旋转掩盖",
part_id=key, functional_role=role, actual=thickness_axis,
expected=expected_axes,
)
except (GraphError, KeyError, TypeError) as exc:
issue("part_geometry", f"/parts/{index}", str(exc))
assemblies = {}
assembly_bounds = {}
allowed_assembly_roles = {"carcass", "drawer", "door", "module", "frame", "hardware_group", "other"}
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 allowed_assembly_roles:
issue(
"assembly_role", f"{path}/role",
"总成必须使用固定结构角色枚举;例如门总成的role是door,不是door_assembly",
assembly_id=key, actual=assembly.get("role"),
allowed=sorted(allowed_assembly_roles),
legal_repair={"op": "replace", "path": f"/assemblies/{key}/role", "value_shape": sorted(allowed_assembly_roles)},
)
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重复、零件不存在或未声明所属总成")
# Legacy graphs may explicitly map local axes. New graphs have
# already declared part dimensions in product X/Y/Z and therefore
# cannot rotate them again at instance level.
axis_order = inst.get("axis_order", [0, 1, 2])
if axis_order not in ([0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]):
raise GraphError("只支持明确的正交轴映射,任意角度需后续几何编译器")
if role_axis_mode and axis_order != [0, 1, 2]:
issue(
"role_axis_template", f"/instances/{key}/axis_order",
"新图谱的零件尺寸已经是产品X宽/Y深/Z高,实例禁止再次交换坐标轴",
instance_id=key, actual=axis_order, expected=[0, 1, 2],
)
size = [parts[inst["part_id"]]["size_mm"][a] for a in axis_order]
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 = (
local_thickness_axis if role_axis_mode else
"xyz"[axis_order.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:
legal_alignment_replacements = []
for row in outside_axes:
axis_name = row["axis"]
legal_alignment_replacements.extend([
{
"op": "replace",
"path": f"/instances/{key}/placement/{axis_name}",
"value": {"mode": "align", "self_anchor": "min", "target": inst["assembly_id"],
"target_anchor": "min", "offset_mm": 0},
},
{
"op": "replace",
"path": f"/instances/{key}/placement/{axis_name}",
"value": {"mode": "align", "self_anchor": "max", "target": inst["assembly_id"],
"target_anchor": "max", "offset_mm": 0},
},
])
issue(
"outside_assembly_bounds", f"/instances/{key}",
f"部件超出所属总成{inst['assembly_id']}的局部边界;不能借用整件产品坐标掩盖模块错位",
instance_id=key, assembly_id=inst["assembly_id"], axes=outside_axes,
current_placement=instance_specs.get(key, {}).get("placement"),
legal_alignment_replacements=legal_alignment_replacements,
correction=(
"先核对零件产品轴尺寸,再选择与所属总成min或max面一致的零偏移锚点;"
"没有独立图像证据不得给多个部件统一追加经验偏移。"
),
)
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()},
"orientation_solver": orientation_solver,
"orientation_audit": orientation_audit,
"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]],
}