优化skill,添加特征树、编辑窗口
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user