2026-09-07 23:30:24 +08:00

1856 lines
94 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 _axis_packing_feasibility(
moving: dict,
instance_rows: list[dict],
bounds: list[list[float]],
*,
axes: list[int] | None = None,
ignored_pairs: set[frozenset] | None = None,
tolerance_mm: float = 0.6,
) -> list[dict]:
"""Find every collision-free lower-coordinate interval on selected axes.
The other two coordinates remain fixed. This is a diagnostic, not an
automatic layout solver: it proves when repeated front/back (or any-axis)
shuffling cannot fit a solid inside its permitted envelope. The caller
still decides whether the source dimension, envelope semantics, or an
explicit machining feature must change.
"""
ignored_pairs = ignored_pairs or set()
selected_axes = list(range(3)) if axes is None else list(axes)
answer = []
for axis in selected_axes:
axis_name = "xyz"[axis]
size = float(moving["size_mm"][axis])
domain_low = float(bounds[axis][0])
domain_high = float(bounds[axis][1]) - size
blockers = []
forbidden = []
if domain_high >= domain_low - tolerance_mm:
for other in instance_rows:
if other["id"] == moving["id"]:
continue
if frozenset((moving["id"], other["id"])) in ignored_pairs:
continue
tangent_axes = [value for value in range(3) if value != axis]
tangent_overlap = [
min(
float(moving["position_mm"][value]) + float(moving["size_mm"][value]),
float(other["position_mm"][value]) + float(other["size_mm"][value]),
) - max(
float(moving["position_mm"][value]),
float(other["position_mm"][value]),
)
for value in tangent_axes
]
if min(tangent_overlap) <= tolerance_mm:
continue
other_low = float(other["position_mm"][axis])
other_high = other_low + float(other["size_mm"][axis])
# Positive-volume overlap occurs strictly inside this interval
# of possible lower coordinates for ``moving``.
forbidden_low = other_low + tolerance_mm - size
forbidden_high = other_high - tolerance_mm
if forbidden_high <= domain_low or forbidden_low >= domain_high:
continue
clipped = [
max(domain_low, forbidden_low),
min(domain_high, forbidden_high),
]
forbidden.append(clipped)
blockers.append({
"instance_id": other["id"],
"forbidden_lower_position_mm": [round(value, 6) for value in clipped],
"tangent_overlap_mm": [round(float(value), 6) for value in tangent_overlap],
})
feasible = [[domain_low, domain_high]] if domain_high >= domain_low else []
for forbidden_low, forbidden_high in sorted(forbidden):
remaining = []
for segment_low, segment_high in feasible:
if forbidden_high <= segment_low or forbidden_low >= segment_high:
remaining.append([segment_low, segment_high])
continue
if forbidden_low > segment_low:
remaining.append([segment_low, min(segment_high, forbidden_low)])
if forbidden_high < segment_high:
remaining.append([max(segment_low, forbidden_high), segment_high])
feasible = remaining
answer.append({
"moving_instance_id": moving["id"],
"axis": axis_name,
"allowed_lower_position_domain_mm": [round(domain_low, 6), round(domain_high, 6)],
"feasible_lower_position_intervals_mm": [
[round(value, 6) for value in interval] for interval in feasible
],
"blocking_instances": blockers,
"feasible_without_dimension_or_bounds_change": bool(feasible),
"source": "one_axis_exact_aabb_interval_subtraction",
})
return answer
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 _validate_hardware_mountings(
instances: dict,
*,
observations: dict,
resolver: "ParameterResolver",
tolerance_mm: float = 0.6,
) -> tuple[list[dict], list[dict]]:
"""Validate explicit hardware-to-host installation geometry.
Hardware is not an ordinary structural box: a knob may protrude beyond a
door's structural envelope and a mortised hinge may occupy a bounded part
of its host. Neither case is a licence to ignore collisions. The graph
must name every host, mounting face and embed depth; this function then
proves contact from solved geometry. It deliberately uses stable roles
and IDs, never product names or SKU templates.
"""
faces = {"x-", "x+", "y-", "y+", "z-", "z+"}
allowed_kinds = {"surface", "mortised", "through"}
audits: list[dict] = []
issues: list[dict] = []
def mounting_issue(code: str, instance_id: str, index: int | None, detail: str, **context):
path = f"/instances/{instance_id}/mountings"
if index is not None:
path += f"/{index}"
issues.append({
"code": code,
"path": path,
"detail": detail,
"repair_owner": "ai",
"instance_id": instance_id,
**context,
})
for instance_id, hardware in instances.items():
raw_mountings = hardware.get("mountings")
is_hardware = hardware.get("functional_role") == "hardware"
if not is_hardware:
if raw_mountings not in (None, []):
mounting_issue(
"mounting_on_non_hardware", instance_id, None,
"只有functional_role=hardware的实例可以声明mountings",
)
continue
if not isinstance(raw_mountings, list) or not raw_mountings:
mounting_issue(
"hardware_mounting_missing", instance_id, None,
"五金实例必须声明至少一个可由几何验证的宿主板件安装关系",
legal_shape={
"host_instance_id": "稳定实例ID", "kind": "surface|mortised|through",
"face": "x-|x+|y-|y+|z-|z+", "embed_depth_mm": 0,
"evidence_ids": ["真实图像证据ID"],
},
)
continue
if len(raw_mountings) > 4:
mounting_issue("hardware_mounting_count", instance_id, None, "单个五金实例最多声明4个安装宿主")
continue
seen_hosts = set()
for index, mounting in enumerate(raw_mountings):
if not isinstance(mounting, dict):
mounting_issue("hardware_mounting_shape", instance_id, index, "安装关系必须是JSON对象")
continue
host_id = mounting.get("host_instance_id")
host = instances.get(host_id)
if not host or host_id == instance_id or host.get("functional_role") == "hardware":
mounting_issue(
"hardware_mounting_host", instance_id, index,
"安装宿主必须是存在且非五金的物理实例",
host_instance_id=host_id,
)
continue
if host_id in seen_hosts:
mounting_issue(
"hardware_mounting_duplicate_host", instance_id, index,
"同一五金不能重复声明同一安装宿主",
host_instance_id=host_id,
)
continue
seen_hosts.add(host_id)
kind, face = mounting.get("kind"), mounting.get("face")
if kind not in allowed_kinds or face not in faces:
mounting_issue(
"hardware_mounting_kind", instance_id, index,
"安装方式只支持surface/mortised/through且必须声明宿主安装面",
actual_kind=kind, actual_face=face,
)
continue
refs = mounting.get("evidence_ids") or []
if not refs or any(ref not in observations for ref in refs):
mounting_issue(
"hardware_mounting_evidence", instance_id, index,
"五金安装关系必须引用本轮真实图像或用户证据",
host_instance_id=host_id,
)
continue
try:
declared_embed = resolver.length(mounting.get("embed_depth_mm", 0))
except (GraphError, TypeError, KeyError) as exc:
mounting_issue(
"hardware_mounting_depth", instance_id, index,
f"安装嵌入深度无效: {exc}", host_instance_id=host_id,
)
continue
axis = "xyz".index(face[0])
host_low = host["position_mm"][axis]
host_high = host_low + host["size_mm"][axis]
hardware_low = hardware["position_mm"][axis]
hardware_high = hardware_low + hardware["size_mm"][axis]
host_plane = host_low if face[1] == "-" else host_high
if face[1] == "+":
actual_embed = max(0.0, host_plane - hardware_low)
gap = max(0.0, hardware_low - host_plane)
else:
actual_embed = max(0.0, hardware_high - host_plane)
gap = max(0.0, host_plane - hardware_high)
tangent_axes = [dimension for dimension in range(3) if dimension != axis]
tangent_overlaps = [
min(
host["position_mm"][dimension] + host["size_mm"][dimension],
hardware["position_mm"][dimension] + hardware["size_mm"][dimension],
) - max(host["position_mm"][dimension], hardware["position_mm"][dimension])
for dimension in tangent_axes
]
host_thickness = host["size_mm"][axis]
depth_tolerance = max(tolerance_mm, declared_embed * 0.1)
geometric_errors = []
host_thickness_axis = host.get("thickness_axis")
if host_thickness_axis in {"x", "y", "z"} and face[0] != host_thickness_axis:
geometric_errors.append(
f"安装面{face}不是宿主板件厚度轴{host_thickness_axis}的广面"
)
if min(tangent_overlaps) <= tolerance_mm:
geometric_errors.append("在安装面上与宿主没有正面积重叠")
if gap > tolerance_mm:
geometric_errors.append(f"与宿主安装面间隙{gap:.3f}mm超差")
if declared_embed < 0 or declared_embed > host_thickness + tolerance_mm:
geometric_errors.append("声明嵌入深度超出宿主法向厚度")
if kind == "surface":
if declared_embed > tolerance_mm:
geometric_errors.append("surface安装的embed_depth_mm必须为0")
if actual_embed > tolerance_mm:
geometric_errors.append(f"表面安装却实际穿入宿主{actual_embed:.3f}mm")
elif kind == "mortised":
if declared_embed <= tolerance_mm:
geometric_errors.append("嵌入式安装必须声明大于公差的嵌入深度")
elif abs(actual_embed - declared_embed) > depth_tolerance:
geometric_errors.append(
f"实际嵌入{actual_embed:.3f}mm与声明{declared_embed:.3f}mm不符"
)
elif kind == "through":
if abs(declared_embed - host_thickness) > tolerance_mm:
geometric_errors.append(
f"贯穿安装应声明宿主全厚{host_thickness:.3f}mm而不是{declared_embed:.3f}mm"
)
if actual_embed < host_thickness - tolerance_mm:
geometric_errors.append(
f"贯穿安装只穿入{actual_embed:.3f}mm,未穿过宿主{host_thickness:.3f}mm厚度"
)
if geometric_errors:
compatible_mounting_values = []
for candidate_face in sorted(faces):
candidate_axis = "xyz".index(candidate_face[0])
if (
host_thickness_axis in {"x", "y", "z"}
and candidate_face[0] != host_thickness_axis
):
continue
candidate_host_low = host["position_mm"][candidate_axis]
candidate_host_high = candidate_host_low + host["size_mm"][candidate_axis]
candidate_hardware_low = hardware["position_mm"][candidate_axis]
candidate_hardware_high = candidate_hardware_low + hardware["size_mm"][candidate_axis]
candidate_plane = (
candidate_host_low if candidate_face[1] == "-" else candidate_host_high
)
if candidate_face[1] == "+":
candidate_embed = max(0.0, candidate_plane - candidate_hardware_low)
candidate_gap = max(0.0, candidate_hardware_low - candidate_plane)
outward_projection = max(0.0, candidate_hardware_high - candidate_plane)
else:
candidate_embed = max(0.0, candidate_hardware_high - candidate_plane)
candidate_gap = max(0.0, candidate_plane - candidate_hardware_high)
outward_projection = max(0.0, candidate_plane - candidate_hardware_low)
candidate_tangent_axes = [
dimension for dimension in range(3) if dimension != candidate_axis
]
candidate_overlaps = [
min(
host["position_mm"][dimension] + host["size_mm"][dimension],
hardware["position_mm"][dimension] + hardware["size_mm"][dimension],
) - max(
host["position_mm"][dimension], hardware["position_mm"][dimension]
)
for dimension in candidate_tangent_axes
]
if candidate_gap > tolerance_mm or min(candidate_overlaps) <= tolerance_mm:
continue
candidate_host_thickness = host["size_mm"][candidate_axis]
if candidate_embed <= tolerance_mm:
candidate_kind, candidate_depth = "surface", 0.0
elif candidate_embed < candidate_host_thickness - tolerance_mm:
candidate_kind, candidate_depth = "mortised", candidate_embed
else:
candidate_kind, candidate_depth = "through", candidate_host_thickness
compatible_mounting_values.append({
"kind": candidate_kind,
"face": candidate_face,
"embed_depth_mm": round(candidate_depth, 6),
"outward_projection_mm": round(outward_projection, 6),
"tangent_overlap_mm": [round(value, 6) for value in candidate_overlaps],
})
compatible_mounting_values.sort(
key=lambda row: (
row["outward_projection_mm"], min(row["tangent_overlap_mm"])
),
reverse=True,
)
mounting_issue(
"hardware_mounting_geometry", instance_id, index,
";".join(geometric_errors), host_instance_id=host_id,
kind=kind, face=face, declared_embed_depth_mm=declared_embed,
actual_embed_depth_mm=actual_embed, gap_mm=gap,
tangent_overlap_mm=tangent_overlaps,
compatible_mounting_values=compatible_mounting_values[:3],
correction=(
"先根据compatible_mounting_values修正kind/face/embed_depth_mm;若无候选,"
"说明五金与该宿主的位置本身不可成立,再修改实例placement或宿主;"
"不得通过改大嵌入深度掩盖错位。"
),
)
continue
audits.append({
"instance_id": instance_id,
"host_instance_id": host_id,
"kind": kind,
"face": face,
"declared_embed_depth_mm": declared_embed,
"actual_embed_depth_mm": actual_embed,
"gap_mm": gap,
"tangent_overlap_mm": tangent_overlaps,
"allows_host_interference": kind in {"mortised", "through"},
"same_assembly": hardware.get("assembly_id") == host.get("assembly_id"),
"source": "declared_mounting_verified_by_aabb",
})
return audits, issues
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))
mounting_audit, mounting_issues = _validate_hardware_mountings(
instances, observations=observations, resolver=resolver,
)
issues.extend(mounting_issues)
valid_mount_pairs = {
frozenset((row["instance_id"], row["host_instance_id"])): row
for row in mounting_audit
}
ignored_interference_pairs = {
pair for pair, row in valid_mount_pairs.items()
if row.get("allows_host_interference")
}
valid_same_assembly_hardware = {
row["instance_id"] for row in mounting_audit if row["same_assembly"]
}
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
# Assembly bounds define the structural placement frame. A valid
# mounted item may protrude from that frame, but only after its
# host, face and contact geometry have passed the mounting gate.
if inst.get("functional_role") == "hardware" and key in valid_same_assembly_hardware:
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
structural_rows = [row for row in rows if row.get("functional_role") != "hardware"]
# A dedicated hardware_group has no structural member. Keep its
# declared bounds check meaningful instead of accepting a void.
tight_rows = structural_rows or rows
actual = [
[min(row["position_mm"][axis] for row in tight_rows),
max(row["position_mm"][axis] + row["size_mm"][axis] for row in tight_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,
bounds_basis=("structural_members" if structural_rows else "hardware_group_members"),
)
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 row in mounting_audit:
neighbours[row["instance_id"]].add(row["host_instance_id"])
neighbours[row["host_instance_id"]].add(row["instance_id"])
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 row in mounting_audit:
if row["same_assembly"]:
internal_neighbours[row["instance_id"]].add(row["host_instance_id"])
internal_neighbours[row["host_instance_id"]].add(row["instance_id"])
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())
structural_members_by_assembly = {
assembly_id: [
row for row in instance_rows
if row.get("assembly_id") == assembly_id
and row.get("functional_role") != "hardware"
]
for assembly_id in assemblies
}
def collision_separation_candidates(a: dict, b: dict) -> list[dict]:
"""Return exact, bounded one-axis moves; never mutate the graph.
The language model still decides which move matches the source views.
Geometry only removes impossible choices and ranks moves that create
the fewest new collisions. A one-member moving assembly receives the
matching tight-bounds patch so a valid door/drawer shift does not
immediately fail a different invariant.
"""
candidates = []
for moving, stationary in ((a, b), (b, a)):
moving_id = moving["id"]
for axis, axis_name in enumerate("xyz"):
moving_low = moving["position_mm"][axis]
moving_high = moving_low + moving["size_mm"][axis]
stationary_low = stationary["position_mm"][axis]
stationary_high = stationary_low + stationary["size_mm"][axis]
deltas = [stationary_low - moving_high, stationary_high - moving_low]
for delta in deltas:
if abs(delta) <= 0.6:
continue
new_low, new_high = moving_low + delta, moving_high + delta
if envelope and (new_low < -0.5 or new_high > envelope[axis] + 0.5):
continue
assembly_id = moving.get("assembly_id")
assembly_patch = None
bounds = assembly_bounds.get(assembly_id)
structural_rows = structural_members_by_assembly.get(assembly_id) or []
if bounds and (new_low < bounds[axis][0] - 0.5 or new_high > bounds[axis][1] + 0.5):
if len(structural_rows) != 1 or structural_rows[0]["id"] != moving_id:
continue
assembly_patch = {
"op": "replace",
"path": f"/assemblies/{assembly_id}/bounds_mm/{axis_name}",
"value": [round(new_low, 6), round(new_high, 6)],
}
elif bounds and len(structural_rows) == 1 and structural_rows[0]["id"] == moving_id:
# Bounds must remain tight even when the moved member
# happens to remain inside the old range.
assembly_patch = {
"op": "replace",
"path": f"/assemblies/{assembly_id}/bounds_mm/{axis_name}",
"value": [round(new_low, 6), round(new_high, 6)],
}
current_rule = (instance_specs.get(moving_id) or {}).get("placement", {}).get(axis_name)
if not isinstance(current_rule, dict):
continue
if current_rule.get("mode") == "align":
replacement = copy.deepcopy(current_rule)
replacement["offset_mm"] = round(
resolver.length(replacement.get("offset_mm", 0)) + delta, 6
)
else:
replacement = {"mode": "absolute", "value_mm": round(new_low, 6)}
simulated = {**moving, "position_mm": list(moving["position_mm"])}
simulated["position_mm"][axis] = new_low
remaining = sum(
1 for other in instance_rows
if (
other["id"] != moving_id
and frozenset((moving_id, other["id"])) not in ignored_interference_pairs
and _aabb_interference(simulated, other)
)
)
patches = [{
"op": "replace",
"path": f"/instances/{moving_id}/placement/{axis_name}",
"value": replacement,
}]
if assembly_patch:
patches.append(assembly_patch)
candidates.append({
"moving_instance_id": moving_id,
"stationary_instance_id": stationary["id"],
"axis": axis_name,
"translation_mm": round(delta, 6),
"new_range_mm": [round(new_low, 6), round(new_high, 6)],
"remaining_collision_count": remaining,
"patches": patches,
})
# A minimum separating translation is the physically conservative
# default. Moving a 20 mm door inward is preferable to relocating an
# entire carcass side by hundreds of millimetres merely because the
# latter happens to touch one fewer already-invalid solid.
candidates.sort(key=lambda row: (abs(row["translation_mm"]), row["remaining_collision_count"]))
return candidates[:6]
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)
mounting = valid_mount_pairs.get(frozenset((a["id"], b["id"])))
if mounting and mounting["allows_host_interference"]:
collision["accepted_as_mounting"] = True
collision["mounting_kind"] = mounting["kind"]
collision["declared_embed_depth_mm"] = mounting["declared_embed_depth_mm"]
continue
minimum_overlap = min(float(value) for value in collision["overlap_mm"])
minimum_axes = [
axis for axis, value in enumerate(collision["overlap_mm"])
if abs(float(value) - minimum_overlap) <= 1e-6
]
packing_feasibility = []
for moving in (a, b):
assembly_id = moving.get("assembly_id")
structural_rows = structural_members_by_assembly.get(assembly_id) or []
permitted_bounds = assembly_bounds.get(assembly_id)
if (
envelope
and len(structural_rows) == 1
and structural_rows[0]["id"] == moving["id"]
):
permitted_bounds = [[0.0, float(value)] for value in envelope]
if not permitted_bounds and envelope:
permitted_bounds = [[0.0, float(value)] for value in envelope]
if permitted_bounds:
packing_feasibility.extend(_axis_packing_feasibility(
moving, instance_rows, permitted_bounds,
axes=minimum_axes,
ignored_pairs=ignored_interference_pairs,
))
minimum_axis_infeasible = bool(packing_feasibility) and all(
not row["feasible_without_dimension_or_bounds_change"]
for row in packing_feasibility
)
issue(
"solid_interference", f"/instances/{b['id']}",
f"实体{a['id']}与{b['id']}发生正体积穿插,不能作为可制造装配",
compatible_separation_candidates=collision_separation_candidates(a, b),
minimum_separation_axes=["xyz"[axis] for axis in minimum_axes],
minimum_axis_packing_feasibility=packing_feasibility,
minimum_separation_axis_infeasible=minimum_axis_infeasible,
repair_scope=(
"source_dimension_or_envelope_semantics_or_explicit_machining_feature"
if minimum_axis_infeasible else "placement_or_dimension"
),
correction=(
(
"最小分离轴在所属边界内没有任何无碰撞位置;禁止在前后障碍之间循环平移。"
"必须回看原图核对外形尺寸口径、覆盖/内嵌关系、零件尺寸,或显式建模槽口/避让;"
"若高置信原图尺寸互相矛盾,必须输出单一聚焦的尺寸确认问题,不得臆造修改。"
if minimum_axis_infeasible else
"必须回看原图选择compatible_separation_candidates中与真实结构一致的最小位移;"
"候选只保证几何可行,不代替图像证据。"
)
),
**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 inst.get("functional_role") == "hardware" and key in valid_same_assembly_hardware:
continue
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,
"mounting_audit": mounting_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]],
}