优化skill,添加特征树、编辑窗口

This commit is contained in:
2026-08-07 17:44:12 +08:00
parent 35e18ca956
commit 5fbcf2c0b8
70 changed files with 13076 additions and 465 deletions
@@ -0,0 +1,298 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cad-agent.local/contracts/solidworks-feature-manager.schema.json",
"title": "SolidWorks FeatureManager style feature_tree.json",
"type": "object",
"required": [
"schema_version",
"tree_kind",
"model",
"source",
"feature_manager",
"rebuild_contract",
"validation"
],
"properties": {
"schema_version": {
"const": "1.0"
},
"tree_kind": {
"const": "solidworks_feature_manager"
},
"model": {
"type": "object",
"required": ["model_id", "units", "document_type"],
"properties": {
"model_id": {},
"family": {},
"units": {
"type": "string"
},
"document_type": {
"const": "part"
}
},
"additionalProperties": true
},
"source": {
"type": "object",
"required": [
"authority",
"reconstruction_mode",
"history_kind",
"claims_original_solidworks_history"
],
"properties": {
"authority": {
"const": "designir-3.0"
},
"designir_path": {},
"step_path": {},
"parameters_path": {},
"reconstruction_mode": {},
"history_kind": {
"enum": [
"authored_semantic",
"hybrid_semantic_surface",
"inferred_from_step",
"unknown"
]
},
"backend": {},
"claims_original_solidworks_history": {
"type": "boolean"
}
},
"additionalProperties": true
},
"feature_manager": {
"type": "object",
"required": ["root_id", "standard_root", "nodes"],
"properties": {
"root_id": {
"type": "string"
},
"standard_root": {
"type": "object"
},
"nodes": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/node"
}
}
},
"additionalProperties": false
},
"rebuild_contract": {
"type": "object",
"required": ["kind", "entrypoint"],
"properties": {
"kind": {
"enum": ["feature_manager_native", "surfaceir_imported_feature"]
},
"entrypoint": {
"type": "string"
},
"backend": {},
"payload_node": {
"type": "string"
},
"payload_encoding": {
"const": "gzip+base64+json"
},
"payload_sha256": {
"type": "string"
}
},
"additionalProperties": true
},
"validation": {
"type": "object"
}
},
"$defs": {
"node": {
"type": "object",
"required": [
"id",
"order",
"solidworks_type",
"display_name",
"english_name",
"children",
"inputs",
"parameters",
"definition",
"dimensions",
"references",
"selection_sets",
"operation_spec",
"result",
"rebuild",
"provenance",
"confidence",
"rebuildable"
],
"properties": {
"id": {
"type": "string",
"minLength": 1
},
"order": {
"type": "integer",
"minimum": 0
},
"solidworks_type": {
"enum": [
"Part",
"HistoryFolder",
"OriginProfileFeature",
"RefPlane",
"RefAxis",
"ProfileFeature",
"BossExtrude",
"CutExtrude",
"Revolve",
"RevolvedCut",
"HoleWizard",
"Fillet",
"Chamfer",
"LinearPattern",
"CircularPattern",
"MirrorPattern",
"ImportedFeature",
"UnsupportedFeature"
]
},
"display_name": {
"type": "string"
},
"english_name": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"inputs": {
"type": "object"
},
"parameters": {
"type": "array",
"items": {
"$ref": "#/$defs/parameter"
}
},
"definition": {
"type": "object"
},
"dimensions": {
"type": "array",
"items": {
"$ref": "#/$defs/dimension"
}
},
"references": {
"type": "object",
"properties": {
"parent": {},
"sketch": {},
"plane": {},
"parent_features": {
"type": "array",
"items": {"type": "string"}
},
"child_features": {
"type": "array",
"items": {"type": "string"}
}
},
"additionalProperties": true
},
"selection_sets": {
"type": "object",
"properties": {
"selected_faces": {"type": "array"},
"selected_edges": {"type": "array"},
"selected_contours": {"type": "array"}
},
"additionalProperties": true
},
"operation_spec": {
"type": "object"
},
"result": {
"type": "object"
},
"rebuild": {
"type": "object",
"properties": {
"suppressed": {"type": "boolean"},
"rollback_order": {"type": "integer"},
"status": {"type": "string"}
},
"additionalProperties": true
},
"provenance": {
"type": "object"
},
"confidence": {
"type": ["number", "null"]
},
"rebuildable": {
"type": "boolean"
}
},
"additionalProperties": false
},
"dimension": {
"type": "object",
"required": ["id", "name", "display_name"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"display_name": {"type": "string"},
"value": {},
"default_value": {},
"unit": {},
"parameter_binding": {},
"driven": {"type": "boolean"}
},
"additionalProperties": true
},
"parameter": {
"type": "object",
"required": ["id", "name", "display_name", "binding_id"],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"display_name": {
"type": "string"
},
"value": {},
"default_value": {},
"unit": {},
"binding_id": {
"type": "string"
},
"editable": {
"type": "boolean"
},
"edit_state": {},
"source_pointer": {
"type": "string"
}
},
"additionalProperties": true
}
},
"additionalProperties": false
}
+541 -8
View File
@@ -231,7 +231,13 @@ def _validate_compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
name
for name in editable
if not parameters[name].get("editable")
or _parameter_reference_count(features, name) == 0
or (
_parameter_reference_count(features, name) == 0
and _parameter_expression_reference_count(
features, payload.get("expressions", {}), name
)
== 0
)
)
if disconnected:
raise DesignIRError(
@@ -307,12 +313,15 @@ def migrate_designir_2_to_3(payload: dict[str, Any]) -> dict[str, Any]:
def compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return the stable flat representation consumed by both CAD backends."""
version = payload.get("schema_version")
if version is None and payload.get("designir_kind") == DESIGNIR_KIND:
version = SCHEMA_VERSION
if version == LEGACY_SCHEMA_VERSION:
return _validate_compiler_payload(copy.deepcopy(payload))
if version != SCHEMA_VERSION or payload.get("designir_kind") != DESIGNIR_KIND:
raise DesignIRError(
"DesignIR must be legacy 2.0 or independent_parametric_cad 3.0"
)
payload = _normalize_tool_authored_designir(payload)
mode = payload.get("reconstruction_mode")
if mode == "surface_parametric":
raise DesignIRError(
@@ -331,8 +340,16 @@ def compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
semantic = payload.get("semantic_layer")
if not isinstance(semantic, dict):
raise DesignIRError("semantic_layer must be an object")
semantic = _normalize_tool_authored_semantic_layer(payload, semantic)
edit_interface = payload.get("edit_interface", {})
validation = payload.get("validation_contract", {})
validation = copy.deepcopy(payload.get("validation_contract", {}))
if isinstance(validation, dict):
validation["perturbations"] = _normalize_validation_list(
validation.get("perturbations", [])
)
validation["invariants"] = _normalize_validation_list(
validation.get("invariants", []), parameter_key="id"
)
flat = {
"schema_version": LEGACY_SCHEMA_VERSION,
"designir_kind": LEGACY_DESIGNIR_KIND,
@@ -366,6 +383,462 @@ def compiler_payload(payload: dict[str, Any]) -> dict[str, Any]:
return _validate_compiler_payload(flat)
def _map_object_to_list(value: Any) -> Any:
if isinstance(value, dict):
return [
{"id": str(key), **item}
if isinstance(item, dict) and "id" not in item
else item
for key, item in value.items()
]
return value
def _map_named_list_to_object(value: Any) -> Any:
if not isinstance(value, list):
return value
mapped: dict[str, Any] = {}
for index, item in enumerate(value):
if not isinstance(item, dict):
continue
name = item.get("name") or item.get("id") or f"item_{index + 1}"
mapped[str(name)] = {
key: child
for key, child in item.items()
if key not in {"name", "id"}
}
return mapped if mapped else value
def _normalize_expression_object(value: Any) -> Any:
if not isinstance(value, list):
return value
mapped: dict[str, Any] = {}
for item in value:
if not isinstance(item, dict):
continue
name = item.get("name") or item.get("id")
expression = item.get("expression") or item.get("value")
if name and expression is not None:
mapped[str(name)] = expression
return mapped if mapped else value
def _normalize_semantic_parameter_names(value: Any) -> list[str]:
if not isinstance(value, list):
return []
names = []
for item in value:
if isinstance(item, str):
names.append(item)
elif isinstance(item, dict):
name = item.get("name") or item.get("id")
if isinstance(name, str):
names.append(name)
return names
def _semantic_parameter_metadata(value: Any) -> dict[str, dict[str, Any]]:
if not isinstance(value, list):
return {}
metadata: dict[str, dict[str, Any]] = {}
for item in value:
if not isinstance(item, dict):
continue
name = item.get("name") or item.get("id")
if isinstance(name, str):
metadata[name] = item
return metadata
def _normalize_coordinate_system(value: Any) -> Any:
if not isinstance(value, dict):
return value
axes = value.get("axes")
if isinstance(axes, dict):
normalized = dict(value)
normalized.setdefault("x_axis", axes.get("x"))
normalized.setdefault("y_axis", axes.get("y"))
normalized.setdefault("z_axis", axes.get("z"))
normalized.pop("axes", None)
return normalized
return value
def _parameter_ref(name: str) -> dict[str, str]:
return {"parameter": name}
def _value_or_parameter_ref(parameters: dict[str, Any], name: str, fallback: Any) -> Any:
return _parameter_ref(name) if name in parameters else fallback
def _expression_or_value_ref(expressions: dict[str, Any], name: str, fallback: Any) -> Any:
return {"expression": name} if name in expressions else fallback
def _circle_profile(sketches: dict[str, Any], sketch_id: Any) -> dict[str, Any] | None:
sketch = sketches.get(str(sketch_id))
if not isinstance(sketch, dict):
return None
profile = sketch.get("profile")
if isinstance(profile, dict) and profile.get("type") == "circle":
return profile
return None
def _center3(profile: dict[str, Any], z: Any = 0) -> list[Any]:
center = profile.get("center", [0, 0])
if not isinstance(center, list):
center = [0, 0]
return [
center[0] if len(center) > 0 else 0,
center[1] if len(center) > 1 else 0,
z,
]
def _diameter_from_profile(
parameters: dict[str, Any],
expressions: dict[str, Any],
profile: dict[str, Any],
*,
diameter_parameter: str,
radius_expression: str,
) -> Any:
if diameter_parameter in parameters:
return _parameter_ref(diameter_parameter)
if radius_expression in expressions:
return {"expression": radius_expression}
radius = profile.get("radius")
if isinstance(radius, (int, float)):
return float(radius) * 2.0
return radius
def _radius_from_profile(
parameters: dict[str, Any],
expressions: dict[str, Any],
profile: dict[str, Any],
*,
radius_parameter: str,
radius_expression: str,
) -> Any:
if radius_parameter in parameters:
return _parameter_ref(radius_parameter)
if radius_expression in expressions:
return {"expression": radius_expression}
return profile.get("radius")
def _distance_xy(center: list[Any]) -> float | None:
if len(center) < 2:
return None
try:
return math.sqrt(float(center[0]) ** 2 + float(center[1]) ** 2)
except (TypeError, ValueError):
return None
def _normalize_common_llm_feature_program(
semantic: dict[str, Any],
) -> list[dict[str, Any]] | None:
raw_features = semantic.get("features")
if not isinstance(raw_features, dict):
return None
sketches = semantic.get("sketches", {})
if not isinstance(sketches, dict):
return None
patterns = semantic.get("patterns", {})
if not isinstance(patterns, dict):
patterns = {}
parameters = semantic.get("parameters", {})
if not isinstance(parameters, dict):
parameters = {}
expressions = semantic.get("expressions", {})
if not isinstance(expressions, dict):
expressions = {}
generated_bodies: list[dict[str, Any]] = []
generated_cuts: list[dict[str, Any]] = []
generated_patterns: list[dict[str, Any]] = []
converted_pattern_sources: set[str] = set()
host_feature_id: str | None = None
for pattern_id, pattern in patterns.items():
if not isinstance(pattern, dict) or pattern.get("type") not in {
"circular",
"polar",
}:
continue
source_feature_id = str(pattern.get("feature") or "")
source_feature = raw_features.get(source_feature_id)
if not isinstance(source_feature, dict):
continue
profile = _circle_profile(sketches, source_feature.get("sketch"))
if profile is None:
continue
center = profile.get("center", [0, 0])
pitch = _value_or_parameter_ref(
parameters,
"bolt_circle_diameter",
None,
)
if pitch is None:
radius = _distance_xy(center if isinstance(center, list) else [])
pitch = radius * 2.0 if radius is not None else pattern.get("pitch_diameter")
generated_patterns.append(
{
"id": str(pattern_id),
"operation": "polar_hole_pattern",
"count": _value_or_parameter_ref(
parameters,
"bolt_count",
pattern.get("count", len(pattern.get("instances", [])) or 1),
),
"diameter": _diameter_from_profile(
parameters,
expressions,
profile,
diameter_parameter="bolt_hole_diameter",
radius_expression="bolt_hole_radius",
),
"pitch_diameter": pitch,
"axis": "primary_axis",
"host": host_feature_id or "flange_body",
}
)
converted_pattern_sources.add(source_feature_id)
for feature_id, feature in raw_features.items():
if not isinstance(feature, dict) or feature_id in converted_pattern_sources:
continue
profile = _circle_profile(sketches, feature.get("sketch"))
if profile is None:
continue
feature_type = str(feature.get("type") or "").lower()
operation = str(feature.get("operation") or "").lower()
if feature_type == "extrude" or operation == "new_body":
generated_feature = {
"id": str(feature_id),
"operation": "extrude_circle",
"radius": _radius_from_profile(
parameters,
expressions,
profile,
radius_parameter="flange_outer_radius",
radius_expression="flange_outer_radius",
),
"height": _value_or_parameter_ref(
parameters,
"flange_thickness",
feature.get("depth", feature.get("height")),
),
"axis": "primary_axis",
"center": _center3(profile),
}
generated_bodies.append(generated_feature)
host_feature_id = str(feature_id)
elif feature_type == "cut_extrude" or operation == "cut":
generated_cuts.append(
{
"id": str(feature_id),
"operation": "through_hole",
"diameter": _diameter_from_profile(
parameters,
expressions,
profile,
diameter_parameter="bore_diameter",
radius_expression="bore_radius",
),
"axis": "primary_axis",
"center": _center3(profile),
"host": host_feature_id or "flange_body",
}
)
generated = generated_bodies + generated_cuts
for pattern_feature in generated_patterns:
if (
pattern_feature.get("host") == "flange_body"
and host_feature_id is not None
):
pattern_feature["host"] = host_feature_id
generated.append(pattern_feature)
if not generated:
return None
return generated
def _normalize_validation_list(value: Any, *, parameter_key: str = "parameter") -> Any:
if isinstance(value, dict):
rows = [
(
{
parameter_key: str(key),
**item,
}
if isinstance(item, dict)
else {parameter_key: str(key), "description": item}
)
for key, item in value.items()
]
elif isinstance(value, list):
rows = [
copy.deepcopy(item)
if isinstance(item, dict)
else {"description": item}
for item in value
]
else:
return value
if parameter_key == "parameter":
for row in rows:
row.setdefault(
"expected_change",
row.get("acceptance") or row.get("description") or "geometry_changes",
)
return rows
def _normalize_feature_aliases(feature: dict[str, Any]) -> dict[str, Any]:
normalized = copy.deepcopy(feature)
operation = normalized.get("operation")
if operation == "polar_hole_pattern":
if "diameter" not in normalized and "hole_diameter" in normalized:
normalized["diameter"] = normalized.pop("hole_diameter")
reference = normalized.get("reference")
if (
"host" not in normalized
and isinstance(reference, dict)
and isinstance(reference.get("feature"), str)
):
normalized["host"] = reference["feature"]
elif operation == "through_hole":
reference = normalized.get("reference")
if (
"host" not in normalized
and isinstance(reference, dict)
and isinstance(reference.get("feature"), str)
):
normalized["host"] = reference["feature"]
if operation in SUPPORTED_OPERATIONS:
normalized.setdefault("axis", "primary_axis")
return normalized
def _normalize_feature_list_aliases(value: Any) -> Any:
features = _map_object_to_list(value)
if not isinstance(features, list):
return features
return [
_normalize_feature_aliases(feature)
if isinstance(feature, dict)
else feature
for feature in features
]
def _normalize_tool_authored_designir(payload: dict[str, Any]) -> dict[str, Any]:
normalized = copy.deepcopy(payload)
normalized.setdefault("schema_version", SCHEMA_VERSION)
if normalized.get("units") is None:
normalized["units"] = "mm"
semantic = normalized.get("semantic_layer")
if isinstance(semantic, dict):
normalized["semantic_layer"] = _normalize_tool_authored_semantic_layer(
normalized, semantic
)
edit_interface = normalized.get("edit_interface")
if isinstance(edit_interface, dict):
edit_interface["semantic_parameters"] = _normalize_semantic_parameter_names(
edit_interface.get("semantic_parameters", [])
)
validation = normalized.get("validation_contract")
if isinstance(validation, dict):
validation["perturbations"] = _normalize_validation_list(
validation.get("perturbations", [])
)
validation["invariants"] = _normalize_validation_list(
validation.get("invariants", []), parameter_key="id"
)
return normalized
def _normalize_tool_authored_semantic_layer(
payload: dict[str, Any], semantic: dict[str, Any]
) -> dict[str, Any]:
normalized = copy.deepcopy(semantic)
if normalized.get("reconstruction_status") in {
"independent",
"fully_parametric",
"fully_semantic_parametric",
}:
normalized["reconstruction_status"] = "ready"
normalized["coordinate_system"] = _normalize_coordinate_system(
normalized.get("coordinate_system")
)
coordinate = normalized.get("coordinate_system")
if isinstance(coordinate, dict):
coordinate.setdefault("origin", [0, 0, 0])
coordinate.setdefault("x_axis", [1, 0, 0])
coordinate.setdefault("y_axis", [0, 1, 0])
coordinate.setdefault("z_axis", [0, 0, 1])
normalized["parameters"] = _map_named_list_to_object(
normalized.get("parameters", {})
)
normalized["expressions"] = _normalize_expression_object(
normalized.get("expressions", {})
)
normalized["datums"] = _map_named_list_to_object(
normalized.get("datums", {})
)
datums = normalized.setdefault("datums", {})
if isinstance(datums, dict):
datums.setdefault("primary_axis", {"kind": "axis", "axis": "z"})
edit_interface = payload.get("edit_interface", {})
editable_names = set(
_normalize_semantic_parameter_names(
edit_interface.get("semantic_parameters", [])
)
if isinstance(edit_interface, dict)
else []
)
edit_metadata = (
_semantic_parameter_metadata(edit_interface.get("semantic_parameters", []))
if isinstance(edit_interface, dict)
else {}
)
parameters = normalized.get("parameters")
if isinstance(parameters, dict):
for name, parameter in parameters.items():
if isinstance(parameter, dict):
parameter.setdefault("editable", name in editable_names)
metadata = edit_metadata.get(name, {})
if metadata.get("label") and not parameter.get("display_name"):
parameter["display_name"] = metadata["label"]
if metadata.get("range") and not parameter.get("range"):
parameter["range"] = metadata["range"]
converted_features = _normalize_common_llm_feature_program(normalized)
if converted_features is not None:
normalized["features"] = converted_features
else:
normalized["features"] = _normalize_feature_list_aliases(
normalized.get("features", [])
)
normalized["sketches"] = _map_object_to_list(normalized.get("sketches", []))
normalized["patterns"] = _map_object_to_list(normalized.get("patterns", []))
normalized["attachments"] = _map_object_to_list(normalized.get("attachments", []))
normalized["constraints"] = _normalize_validation_list(
normalized.get("constraints", []), parameter_key="id"
)
normalized["construction_stages"] = _map_object_to_list(
normalized.get("construction_stages", [])
)
return normalized
def validate_designir(payload: dict[str, Any]) -> dict[str, Any]:
"""Validate either supported version and return its compiler representation."""
return compiler_payload(payload)
@@ -423,7 +896,7 @@ def resolve_value(value: Any, values: dict[str, float], label: str) -> float:
if isinstance(value, dict) and set(value) == {"expression"}:
name = str(value["expression"])
if name not in values:
raise DesignIRError(f"{label} references unknown expression {name}")
return _safe_expression(name, values)
return values[name]
raise DesignIRError(f"{label} must be a number, parameter, or expression")
@@ -578,10 +1051,9 @@ def build_shape(payload: dict[str, Any]) -> Part:
if shape is None:
raise DesignIRError("DesignIR produced no geometry")
result = Part(shape.wrapped)
if not list(result.solids()) or result.volume <= 0:
if not list(shape.solids()) or shape.volume <= 0:
raise DesignIRError("DesignIR did not produce a valid solid")
return result
return shape
def build_simplecad_shape(payload: dict[str, Any]) -> tuple[Any, str]:
@@ -920,9 +1392,26 @@ def _distance(first: list[float], second: list[float]) -> float:
return math.sqrt(sum((a - b) ** 2 for a, b in zip(first, second)))
def _boolean_result_volume(value: Any) -> float:
volume = getattr(value, "volume", None)
if isinstance(volume, (int, float)):
return float(volume)
try:
return sum(
float(getattr(item, "volume", 0.0))
for item in value
)
except TypeError as exc:
raise DesignIRError(
f"Boolean result has no measurable volume: {type(value).__name__}"
) from exc
def _symmetric_difference_volume(first: Part, second: Part) -> float:
try:
return float((first - second).volume + (second - first).volume)
return _boolean_result_volume(first - second) + _boolean_result_volume(
second - first
)
except Exception as exc: # OpenCascade failures become an explicit metric state.
raise DesignIRError(f"Symmetric-difference boolean failed: {exc}") from exc
@@ -939,6 +1428,50 @@ def _parameter_reference_count(value: Any, parameter: str) -> int:
return 0
def _expression_reference_names(value: Any) -> set[str]:
if isinstance(value, dict):
direct = {str(value["expression"])} if isinstance(value.get("expression"), str) else set()
return direct.union(
*(_expression_reference_names(child) for child in value.values())
)
if isinstance(value, list):
names: set[str] = set()
for child in value:
names.update(_expression_reference_names(child))
return names
return set()
def _expression_reference_values(value: Any) -> set[str]:
if isinstance(value, dict):
direct = {str(value["expression"])} if isinstance(value.get("expression"), str) else set()
values = set(direct)
for child in value.values():
values.update(_expression_reference_values(child))
return values
if isinstance(value, list):
values: set[str] = set()
for child in value:
values.update(_expression_reference_values(child))
return values
return set()
def _parameter_expression_reference_count(
value: Any, expressions: Any, parameter: str
) -> int:
if not isinstance(expressions, dict):
expressions = {}
count = 0
for expression_value in _expression_reference_values(value):
expression = expressions.get(expression_value, expression_value)
if isinstance(expression, str):
count += len(
re.findall(rf"\b{re.escape(parameter)}\b", expression)
)
return count
def acceptance_report(
teacher_path: Path,
rebuilt_path: Path,
@@ -1186,7 +1719,7 @@ def main(argv: list[str] | None = None) -> int:
migrated = (
migrate_designir_2_to_3(original)
if original.get("schema_version") == LEGACY_SCHEMA_VERSION
else original
else _normalize_tool_authored_designir(original)
)
compiler_payload(migrated)
write_json(args.output, migrated)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""Normalize backend-native CAD generation artifacts into DesignIR 3.0.
This script intentionally treats backend-native source or model graph files as
the editable authority. DesignIR records the contract, parameters, validation
evidence, and a non-authoritative SurfaceIR snapshot compiled from the generated
STEP.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Any
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from designir_codec import write_designir
import surfaceir_pipeline
def _safe_id(value: str, fallback: str) -> str:
token = re.sub(r"[^a-zA-Z0-9_.-]+", "_", value.strip()).strip("._-")
return token or fallback
def _read_json(path: Path | None) -> dict[str, Any]:
if path is None:
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
if not isinstance(payload, dict):
return {}
return payload
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _parameter_record(
item: dict[str, Any],
*,
backend: str,
source_path: str | None,
graph_path: str | None,
) -> tuple[str, dict[str, Any]] | None:
name = str(item.get("name") or item.get("id") or "").strip()
if not name:
return None
try:
value = float(item.get("value"))
except (TypeError, ValueError):
return None
binding_kind = str(item.get("binding_kind") or "python_constant")
binding_source = str(item.get("source_path") or source_path or "")
if binding_kind == "model_graph_parameter" and graph_path:
binding_source = str(item.get("source_path") or graph_path)
minimum = item.get("min", item.get("minimum"))
maximum = item.get("max", item.get("maximum"))
record: dict[str, Any] = {
"value": value,
"unit": str(item.get("unit") or "mm"),
"editable": bool(item.get("editable", True)),
"semantic_role": str(item.get("semantic_role") or name),
"display_name": item.get("display_name") or item.get("label") or name,
"description": item.get("description") or "",
"edit_state": str(item.get("edit_state") or "backend_bound_parameter"),
"backend_binding": {
"backend": backend,
"source_path": binding_source,
"binding_kind": binding_kind,
"parameter_path": str(item.get("parameter_path") or f"PARAMETERS.{name}"),
"regenerate_adapter": str(item.get("regenerate_adapter") or backend),
},
}
if minimum is not None:
record["min"] = minimum
if maximum is not None:
record["max"] = maximum
for key in ("step", "precision", "group", "validated_range"):
if key in item:
record[key] = item[key]
return name, record
def normalize_backend_result(
*,
request: str,
backend: str,
source_of_truth: str,
step_path: Path,
output_path: Path,
model_id: str,
family: str,
native_source_path: str | None,
backend_graph_path: str | None,
metadata_path: Path | None,
validation_paths: list[str],
) -> dict[str, Any]:
metadata = _read_json(metadata_path)
parameters: dict[str, Any] = {}
for item in metadata.get("parameters", []):
if isinstance(item, dict):
parsed = _parameter_record(
item,
backend=backend,
source_path=native_source_path,
graph_path=backend_graph_path,
)
if parsed:
name, record = parsed
parameters[name] = record
features = []
for index, item in enumerate(metadata.get("features", []), start=1):
if not isinstance(item, dict):
continue
feature_id = str(item.get("id") or f"backend_feature_{index}")
features.append(
{
**item,
"id": feature_id,
"operation": str(item.get("operation") or "backend_native_feature"),
"backend": backend,
"original_history_recovered": False,
"source_of_truth": source_of_truth,
}
)
if not features:
features.append(
{
"id": "backend_native_model",
"operation": "backend_native_feature",
"backend": backend,
"source_of_truth": source_of_truth,
"original_history_recovered": False,
"description": "Model was authored by the selected backend's native generation flow.",
}
)
surface_payload: dict[str, Any] | None = None
surface_snapshot: dict[str, Any] = {
"status": "materialized",
"source": "generated_step",
}
try:
surface_payload = surfaceir_pipeline.extract_surfaceir(step_path)
except Exception as exc:
# SurfaceIR is a non-authoritative snapshot for backend-native models.
# Unsupported STEP surface vocabulary must not discard a valid STEP,
# native source, or replayable model graph.
surface_snapshot = {
"status": "unavailable",
"source": "generated_step",
"error": str(exc),
}
has_surface_snapshot = surface_payload is not None
reconstruction_mode = (
"hybrid_semantic_surface_parametric"
if has_surface_snapshot
else "fully_semantic_parametric"
)
designir: dict[str, Any] = {
"schema_version": "3.0",
"designir_kind": "independent_parametric_cad",
"model_id": model_id,
"family": family,
"units": str(metadata.get("units") or "mm"),
"document_status": (
surface_payload.get("document_status", "geometry_present")
if surface_payload
else "geometry_present"
),
"reconstruction_mode": reconstruction_mode,
"authoring_mode": "semantic_feature_program",
"backend_hint": backend,
"source_of_truth": {
"kind": source_of_truth,
"backend": backend,
"native_source_path": native_source_path,
"backend_graph_path": backend_graph_path,
"primary_step_path": str(step_path),
"primary_step_sha256": _sha256(step_path),
},
"semantic_layer": {
"reconstruction_status": "ready" if has_surface_snapshot else "partial",
"coordinate_system": {
"origin": [0, 0, 0],
"x_axis": [1, 0, 0],
"y_axis": [0, 1, 0],
"z_axis": [0, 0, 1],
},
"datums": {
"primary_axis": {"kind": "axis", "axis": "z"},
},
"parameters": parameters,
"expressions": {},
"sketches": [],
"constraints": [],
"features": features,
"patterns": [],
"attachments": [
{
"id": "backend_native_authority",
"kind": "source_of_truth",
"backend": backend,
"source_of_truth": source_of_truth,
"native_source_path": native_source_path,
"backend_graph_path": backend_graph_path,
}
],
"construction_stages": [
{
"id": "backend_native_generation",
"kind": "backend_generation",
"backend": backend,
"validation_artifacts": validation_paths,
},
{
"id": "surfaceir_snapshot",
"kind": "non_authoritative_surface_snapshot",
**surface_snapshot,
},
],
},
"edit_interface": {
"semantic_parameters": [
name
for name, record in parameters.items()
if record.get("editable") is True and record.get("backend_binding")
],
"surface_parameter_groups": (
surface_payload.get("edit_interface", {}).get(
"surface_parameter_groups", []
)
if surface_payload
else []
),
"modification_levels": ["backend_native_parameter", "semantic_feature"],
"preserved_interfaces": [],
},
"validation_contract": {
"source_independence": True,
"geometry_checks": (
["backend_native_generation", "step_surfaceir_materialization"]
if has_surface_snapshot
else ["backend_native_generation"]
),
"edit_checks": ["backend_bound_parameter_regeneration"],
"invariants": [],
"perturbations": [],
"thresholds": {},
"backend_validation_artifacts": validation_paths,
"surface_snapshot": surface_snapshot,
},
}
if surface_payload:
designir["surface_layer"] = surface_payload["surface_layer"]
designir["reconstruction_strategy"] = surface_payload.get(
"reconstruction_strategy",
{"boundary_strategy": "exact_3d", "selection_status": "default"},
)
designir["compiled_surface_provenance"] = {
"source": "generated_step",
"teacher_geometry_used": False,
"semantic_layer_authoritative": True,
}
write_designir(output_path, designir)
return {
"valid": True,
"output": str(output_path.expanduser().resolve()),
"schema_version": "3.0",
"reconstruction_mode": designir["reconstruction_mode"],
"backend": backend,
"source_of_truth": source_of_truth,
"parameter_count": len(parameters),
"feature_count": len(features),
"surface_snapshot": surface_snapshot,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--request", required=True)
parser.add_argument("--backend", choices=("build123d", "simplecadapi", "surfaceir"), required=True)
parser.add_argument(
"--source-of-truth",
choices=("native_source", "model_graph", "surfaceir_designir"),
required=True,
)
parser.add_argument("--step", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--model-id", required=True)
parser.add_argument("--family", default="backend_native_model")
parser.add_argument("--native-source-path")
parser.add_argument("--backend-graph-path")
parser.add_argument("--metadata", type=Path)
parser.add_argument("--validation-artifact", action="append", default=[])
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = normalize_backend_result(
request=args.request,
backend=args.backend,
source_of_truth=args.source_of_truth,
step_path=args.step.expanduser().resolve(),
output_path=args.output.expanduser().resolve(),
model_id=_safe_id(args.model_id, "backend_native_model"),
family=args.family,
native_source_path=args.native_source_path,
backend_graph_path=args.backend_graph_path,
metadata_path=args.metadata,
validation_paths=args.validation_artifact,
)
except Exception as exc:
print(json.dumps({"valid": False, "error": str(exc)}, ensure_ascii=False, indent=2))
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -15,6 +15,14 @@ MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC.loader
SPEC.loader.exec_module(MODULE)
FEATURE_TREE_SCRIPT = ROOT / "scripts" / "feature_tree.py"
FEATURE_TREE_SPEC = importlib.util.spec_from_file_location(
"feature_tree", FEATURE_TREE_SCRIPT
)
FEATURE_TREE = importlib.util.module_from_spec(FEATURE_TREE_SPEC)
assert FEATURE_TREE_SPEC.loader
FEATURE_TREE_SPEC.loader.exec_module(FEATURE_TREE)
class DesignIRPipelineTests(unittest.TestCase):
def payload(self) -> dict:
@@ -66,6 +74,257 @@ class DesignIRPipelineTests(unittest.TestCase):
MODULE.compile_designir(hybrid, rebuilt)
self.assertTrue(rebuilt.is_file())
def test_feature_tree_is_derived_from_semantic_designir(self) -> None:
migrated = MODULE.migrate_designir_2_to_3(self.payload())
tree = FEATURE_TREE.generate_feature_tree(
migrated,
designir_path="flange.designir.json",
step_path="flange.step",
backend="build123d",
compiled=True,
validated=True,
)
self.assertEqual("solidworks_feature_manager", tree["tree_kind"])
self.assertEqual("1.0", tree["schema_version"])
serialized = json.dumps(tree)
self.assertNotIn('"designir_3"', serialized)
self.assertNotIn('"source_designir_feature"', serialized)
self.assertNotIn('"manufacturing"', serialized)
self.assertEqual(
"authored_semantic",
tree["source"]["history_kind"],
)
self.assertEqual(
"valid",
tree["validation"]["structure"]["status"],
)
nodes = {
node["id"]: node
for node in tree["feature_manager"]["nodes"]
}
self.assertIn("sw:front_plane", nodes)
self.assertIn("sketch:base_flange", nodes)
self.assertIn("feature:base_flange", nodes)
self.assertIn("feature:central_bore", nodes)
self.assertEqual(
"BossExtrude",
nodes["feature:base_flange"]["solidworks_type"],
)
self.assertEqual(
"BossExtrude",
nodes["feature:base_flange"]["definition"]["feature_type"],
)
self.assertTrue(nodes["feature:base_flange"]["dimensions"])
self.assertIn(
"feature:central_bore",
nodes["feature:base_flange"]["references"]["child_features"],
)
self.assertEqual(
"CutExtrude",
nodes["feature:central_bore"]["solidworks_type"],
)
self.assertEqual(
"ThroughAll",
nodes["feature:central_bore"]["definition"]["end_condition"],
)
self.assertEqual(
[],
nodes["feature:central_bore"]["selection_sets"]["selected_edges"],
)
self.assertFalse(nodes["feature:central_bore"]["rebuild"]["suppressed"])
self.assertIn(
"凸台-拉伸",
nodes["feature:base_flange"]["display_name"],
)
self.assertEqual(
["bore_diameter"],
[
parameter["binding_id"]
for parameter in nodes["feature:central_bore"]["parameters"]
],
)
replay_designir = FEATURE_TREE.designir_from_feature_tree(tree)
self.assertEqual("3.0", replay_designir["schema_version"])
self.assertEqual(
"feature_manager_tree_replay",
replay_designir["authoring_mode"],
)
with tempfile.TemporaryDirectory() as temporary:
original_step = Path(temporary) / "original.step"
replay_step = Path(temporary) / "replayed.step"
original = MODULE.compile_designir(migrated, original_step)
result = FEATURE_TREE.compile_feature_tree(tree, replay_step)
self.assertTrue(replay_step.is_file())
self.assertEqual("feature_manager_native", result["replay_kind"])
for key in ("solid_count", "face_count", "edge_count"):
self.assertEqual(original["facts"][key], result["facts"][key])
self.assertEqual(original["facts"]["size_mm"], result["facts"]["size_mm"])
self.assertAlmostEqual(
original["facts"]["volume_mm3"],
result["facts"]["volume_mm3"],
places=7,
)
def test_llm_flange_variant_normalizes_and_compiles(self) -> None:
payload = {
"designir_kind": "independent_parametric_cad",
"reconstruction_mode": "fully_semantic_parametric",
"authoring_mode": "semantic_feature_program",
"semantic_layer": {
"reconstruction_status": "ready",
"coordinate_system": {
"origin": [0, 0, 0],
"x_axis": [1, 0, 0],
"y_axis": [0, 1, 0],
"z_axis": [0, 0, 1],
},
"datums": [
{
"id": "datum_xy",
"plane": {"normal": [0, 0, 1], "offset": 0},
}
],
"parameters": [
{
"name": "flange_outer_diameter",
"value": 120,
"unit": "mm",
"editable": True,
},
{
"name": "flange_thickness",
"value": 16,
"unit": "mm",
"editable": True,
},
{
"name": "bore_diameter",
"value": 40,
"unit": "mm",
"editable": True,
},
{
"name": "bolt_circle_diameter",
"value": 90,
"unit": "mm",
"editable": True,
},
{
"name": "bolt_hole_diameter",
"value": 11,
"unit": "mm",
"editable": True,
},
{
"name": "bolt_count",
"value": 4,
"unit": "count",
"editable": True,
},
],
"expressions": [
{
"name": "flange_outer_radius",
"expression": "flange_outer_diameter / 2",
}
],
"sketches": [],
"constraints": [],
"features": [
{
"id": "feat_flange_body",
"operation": "extrude_circle",
"radius": {
"expression": "flange_outer_diameter / 2"
},
"height": {"parameter": "flange_thickness"},
},
{
"id": "feat_central_bore",
"operation": "through_hole",
"diameter": {"parameter": "bore_diameter"},
"reference": {"feature": "feat_flange_body"},
},
{
"id": "feat_bolt_holes",
"operation": "polar_hole_pattern",
"count": {"parameter": "bolt_count"},
"pitch_diameter": {
"parameter": "bolt_circle_diameter"
},
"hole_diameter": {
"parameter": "bolt_hole_diameter"
},
"reference": {"feature": "feat_flange_body"},
},
],
"patterns": [],
"attachments": [],
"construction_stages": [],
},
"edit_interface": {
"semantic_parameters": [
{"name": "flange_outer_diameter", "label": "法兰外径"},
{"name": "flange_thickness", "label": "法兰厚度"},
{"name": "bore_diameter", "label": "中心孔径"},
{"name": "bolt_circle_diameter", "label": "分布圆直径"},
{"name": "bolt_hole_diameter", "label": "螺栓孔径"},
{"name": "bolt_count", "label": "螺栓孔数量"},
],
"surface_parameter_groups": [],
"modification_levels": ["semantic_feature"],
"preserved_interfaces": [],
},
"validation_contract": {
"source_independence": True,
"geometry_checks": [],
"edit_checks": [],
"invariants": [],
"perturbations": [
{"parameter": "flange_outer_diameter", "delta": 10}
],
"thresholds": {},
},
"backend_hint": "build123d",
}
normalized = MODULE._normalize_tool_authored_designir(payload)
self.assertEqual("3.0", normalized["schema_version"])
self.assertEqual("mm", normalized["units"])
self.assertIsInstance(
normalized["semantic_layer"]["parameters"], dict
)
self.assertEqual(
["flange_outer_diameter", "flange_thickness", "bore_diameter",
"bolt_circle_diameter", "bolt_hole_diameter", "bolt_count"],
normalized["edit_interface"]["semantic_parameters"],
)
compiled = MODULE.validate_designir(payload)
self.assertEqual(
{"parameter": "bolt_hole_diameter"},
compiled["features"][2]["diameter"],
)
self.assertEqual("feat_flange_body", compiled["features"][1]["host"])
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary) / "flange.step"
result = MODULE.compile_designir(payload, output)
self.assertEqual(1, result["facts"]["solid_count"])
self.assertEqual([120.0, 120.0, 16.0], result["facts"]["size_mm"])
tree = FEATURE_TREE.generate_feature_tree(
normalized,
designir_path="flange.designir.json",
step_path="flange.step",
backend="build123d",
compiled=True,
validated=True,
)
serialized = json.dumps(tree)
self.assertNotIn('"designir_3"', serialized)
self.assertNotIn('"source_designir_feature"', serialized)
replay_step = Path(temporary) / "flange-replayed.step"
replay = FEATURE_TREE.compile_feature_tree(tree, replay_step)
self.assertEqual("feature_manager_native", replay["replay_kind"])
self.assertEqual(result["facts"]["size_mm"], replay["facts"]["size_mm"])
def test_teacher_dependency_is_rejected(self) -> None:
payload = self.payload()
payload["source_step"] = "teacher.step"
@@ -0,0 +1,75 @@
from __future__ import annotations
import importlib.util
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "normalize_backend_result.py"
SPEC = importlib.util.spec_from_file_location("normalize_backend_result", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = MODULE
SPEC.loader.exec_module(MODULE)
class NormalizeBackendResultTests(unittest.TestCase):
def test_unsupported_surfaceir_snapshot_does_not_block_native_publish(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
step_path = directory / "model.step"
metadata_path = directory / "backend-metadata.json"
output_path = directory / "model.designir.json"
step_path.write_bytes(b"ISO-10303-21;\nEND-ISO-10303-21;\n")
metadata_path.write_text(
json.dumps(
{
"parameters": [
{
"name": "module",
"value": 2.5,
"unit": "mm",
"editable": True,
"binding_kind": "native_python",
"parameter_path": "PARAMETERS.module",
"regenerate_adapter": "simplecadapi",
}
]
}
),
encoding="utf-8",
)
with patch.object(
MODULE.surfaceir_pipeline,
"extract_surfaceir",
side_effect=ValueError("Unsupported surface type: 8"),
):
result = MODULE.normalize_backend_result(
request="standard spur gear",
backend="simplecadapi",
source_of_truth="model_graph",
step_path=step_path,
output_path=output_path,
model_id="spur_gear",
family="simplecadapi_native_model",
native_source_path="model.simplecadapi.py",
backend_graph_path="model.simplecad.model.json",
metadata_path=metadata_path,
validation_paths=["backend-validation.json"],
)
payload = json.loads(output_path.read_text(encoding="utf-8"))
self.assertTrue(result["valid"])
self.assertEqual("fully_semantic_parametric", result["reconstruction_mode"])
self.assertEqual("unavailable", result["surface_snapshot"]["status"])
self.assertNotIn("surface_layer", payload)
self.assertEqual("partial", payload["semantic_layer"]["reconstruction_status"])
snapshot_stage = payload["semantic_layer"]["construction_stages"][1]
self.assertEqual("unavailable", snapshot_stage["status"])
self.assertEqual("Unsupported surface type: 8", snapshot_stage["error"])
@@ -26,6 +26,9 @@ SURFACEIR = load_module(
DESIGNIR = load_module(
"designir_pipeline_surface_test", ROOT / "scripts" / "designir_pipeline.py"
)
FEATURE_TREE = load_module(
"feature_tree_surface_test", ROOT / "scripts" / "feature_tree.py"
)
class SurfaceIRPipelineTests(unittest.TestCase):
@@ -128,6 +131,59 @@ class SurfaceIRPipelineTests(unittest.TestCase):
DESIGNIR._symmetric_difference_volume(expected, actual), 1e-8
)
def test_feature_tree_for_uploaded_step_is_inferred_summary(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
teacher = directory / "teacher.step"
DESIGNIR.export_step(DESIGNIR.Box(10, 20, 30), teacher)
payload = SURFACEIR.extract_surfaceir(teacher)
tree = FEATURE_TREE.generate_feature_tree(
payload,
designir_path="box.designir.json",
step_path="box.reconstructed.step",
parameters_path="parameters.json",
backend="surfaceir_occt",
compiled=True,
validated=True,
)
self.assertEqual(
"inferred_from_step",
tree["source"]["history_kind"],
)
self.assertEqual("solidworks_feature_manager", tree["tree_kind"])
self.assertFalse(
tree["source"]["claims_original_solidworks_history"]
)
imported = next(
node
for node in tree["feature_manager"]["nodes"]
if node["solidworks_type"] == "ImportedFeature"
)
self.assertEqual("导入1", imported["display_name"])
self.assertEqual(
"ImportedFeature",
imported["definition"]["feature_type"],
)
self.assertFalse(imported["definition"]["native_history_recovered"])
self.assertNotIn("manufacturing", imported)
self.assertEqual(1, imported["result"]["solid_count"])
self.assertEqual(
"gzip+base64+json",
imported["operation_spec"]["payload_encoding"],
)
self.assertEqual(
"fallback_imported_feature",
tree["validation"]["feature_recognition"]["status"],
)
replay_designir = FEATURE_TREE.designir_from_feature_tree(tree)
self.assertEqual(payload["surface_layer"], replay_designir["surface_layer"])
replayed = directory / "replayed_from_tree.step"
result = FEATURE_TREE.compile_feature_tree(tree, replayed)
self.assertTrue(replayed.is_file())
self.assertEqual("surfaceir_imported_feature", result["replay_kind"])
serialized = json.dumps(tree)
self.assertNotIn('"surface_layer": {"vertices"', serialized)
def test_empty_step_document_is_represented_honestly(self) -> None:
payload = {
"schema_version": "3.0",