1590 lines
58 KiB
Python
1590 lines
58 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate and replay SolidWorks FeatureManager-style CAD feature trees.
|
|
|
|
The exported ``feature_tree.json`` is a normalized, user-facing construction
|
|
history. It is intentionally not a claim that an uploaded STEP file contained
|
|
native SolidWorks history. STEP/SurfaceIR inputs fall back to a legal
|
|
SolidWorks-style imported feature that embeds the replay payload.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import copy
|
|
import gzip
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
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 read_designir, write_designir
|
|
|
|
|
|
SCHEMA_VERSION = "1.0"
|
|
TREE_KIND = "solidworks_feature_manager"
|
|
SUPPORTED_SW_TYPES = {
|
|
"Part",
|
|
"HistoryFolder",
|
|
"OriginProfileFeature",
|
|
"RefPlane",
|
|
"RefAxis",
|
|
"ProfileFeature",
|
|
"BossExtrude",
|
|
"CutExtrude",
|
|
"Revolve",
|
|
"RevolvedCut",
|
|
"HoleWizard",
|
|
"Fillet",
|
|
"Chamfer",
|
|
"LinearPattern",
|
|
"CircularPattern",
|
|
"MirrorPattern",
|
|
"ImportedFeature",
|
|
"UnsupportedFeature",
|
|
}
|
|
DESIGNIR_TO_SW = {
|
|
"extrude_circle": "BossExtrude",
|
|
"extrude_rectangle": "BossExtrude",
|
|
"add_cylinder": "BossExtrude",
|
|
"through_hole": "CutExtrude",
|
|
"polar_hole_pattern": "CircularPattern",
|
|
"revolve": "Revolve",
|
|
"revolved_cut": "RevolvedCut",
|
|
"hole_wizard": "HoleWizard",
|
|
"fillet": "Fillet",
|
|
"chamfer": "Chamfer",
|
|
"linear_pattern": "LinearPattern",
|
|
"mirror_pattern": "MirrorPattern",
|
|
}
|
|
DISPLAY_PREFIX = {
|
|
"BossExtrude": "凸台-拉伸",
|
|
"CutExtrude": "切除-拉伸",
|
|
"Revolve": "旋转凸台/基体",
|
|
"RevolvedCut": "旋转切除",
|
|
"HoleWizard": "孔向导",
|
|
"Fillet": "圆角",
|
|
"Chamfer": "倒角",
|
|
"LinearPattern": "线性阵列",
|
|
"CircularPattern": "圆周阵列",
|
|
"MirrorPattern": "镜像",
|
|
"ImportedFeature": "导入",
|
|
"UnsupportedFeature": "未支持特征",
|
|
}
|
|
SKETCH_PREFIX = "草图"
|
|
STANDARD_NODES = [
|
|
("sw:history", "HistoryFolder", "历史", "History"),
|
|
("sw:origin_folder", "HistoryFolder", "原点", "Origin"),
|
|
("sw:front_plane", "RefPlane", "前视基准面", "Front Plane"),
|
|
("sw:top_plane", "RefPlane", "上视基准面", "Top Plane"),
|
|
("sw:right_plane", "RefPlane", "右视基准面", "Right Plane"),
|
|
("sw:origin", "OriginProfileFeature", "原点", "Origin"),
|
|
]
|
|
|
|
|
|
class FeatureTreeError(ValueError):
|
|
pass
|
|
|
|
|
|
def _json_pointer(parts: list[str | int]) -> str:
|
|
def encode(part: str | int) -> str:
|
|
return str(part).replace("~", "~0").replace("/", "~1")
|
|
|
|
return "/" + "/".join(encode(part) for part in parts)
|
|
|
|
|
|
def _slug(value: Any, fallback: str = "item") -> str:
|
|
text = re.sub(r"[^a-zA-Z0-9_.-]+", "_", str(value or "").strip())
|
|
return text.strip("._-") or fallback
|
|
|
|
|
|
def _history_kind(payload: dict[str, Any]) -> str:
|
|
mode = payload.get("reconstruction_mode")
|
|
if mode == "fully_semantic_parametric":
|
|
return "authored_semantic"
|
|
if mode == "hybrid_semantic_surface_parametric":
|
|
return "hybrid_semantic_surface"
|
|
if mode == "surface_parametric":
|
|
return "inferred_from_step"
|
|
return "unknown"
|
|
|
|
|
|
def _require_designir_3(payload: dict[str, Any]) -> dict[str, Any]:
|
|
if payload.get("schema_version") != "3.0":
|
|
raise FeatureTreeError(
|
|
"feature_tree.json only accepts DesignIR 3.0; migrate legacy DesignIR before generating a tree"
|
|
)
|
|
if payload.get("designir_kind") != "independent_parametric_cad":
|
|
raise FeatureTreeError("DesignIR kind must be independent_parametric_cad")
|
|
result = copy.deepcopy(payload)
|
|
semantic = result.setdefault("semantic_layer", {})
|
|
if not isinstance(semantic, dict):
|
|
raise FeatureTreeError("semantic_layer must be an object")
|
|
semantic.setdefault("datums", {})
|
|
semantic.setdefault("parameters", {})
|
|
semantic.setdefault("expressions", {})
|
|
semantic.setdefault("sketches", [])
|
|
semantic.setdefault("features", [])
|
|
semantic.setdefault("patterns", [])
|
|
semantic.setdefault("attachments", [])
|
|
semantic.setdefault("construction_stages", [])
|
|
return result
|
|
|
|
|
|
def _node(
|
|
*,
|
|
node_id: str,
|
|
order: int,
|
|
solidworks_type: str,
|
|
display_name: str,
|
|
english_name: str,
|
|
children: list[str] | None = None,
|
|
inputs: dict[str, Any] | None = None,
|
|
parameters: list[dict[str, Any]] | None = None,
|
|
definition: dict[str, Any] | None = None,
|
|
dimensions: list[dict[str, Any]] | None = None,
|
|
references: dict[str, Any] | None = None,
|
|
selection_sets: dict[str, Any] | None = None,
|
|
operation_spec: dict[str, Any] | None = None,
|
|
result: dict[str, Any] | None = None,
|
|
rebuild: dict[str, Any] | None = None,
|
|
provenance: str | dict[str, Any] = "generated_from_designir",
|
|
confidence: float | None = 1.0,
|
|
rebuildable: bool = True,
|
|
) -> dict[str, Any]:
|
|
if solidworks_type not in SUPPORTED_SW_TYPES:
|
|
solidworks_type = "UnsupportedFeature"
|
|
provenance_payload = (
|
|
{"source": provenance} if isinstance(provenance, str) else provenance
|
|
)
|
|
node_inputs = inputs or {}
|
|
node_children = children or []
|
|
node_parameters = parameters or []
|
|
node_operation_spec = operation_spec or {}
|
|
node_rebuildable = bool(rebuildable)
|
|
return {
|
|
"id": node_id,
|
|
"order": order,
|
|
"solidworks_type": solidworks_type,
|
|
"display_name": display_name,
|
|
"english_name": english_name,
|
|
"children": node_children,
|
|
"inputs": node_inputs,
|
|
"parameters": node_parameters,
|
|
"definition": definition or _default_definition(
|
|
solidworks_type,
|
|
display_name,
|
|
node_inputs,
|
|
node_operation_spec,
|
|
),
|
|
"dimensions": dimensions or [],
|
|
"references": references or _default_references(node_inputs, node_children),
|
|
"selection_sets": selection_sets or _empty_selection_sets(),
|
|
"operation_spec": node_operation_spec,
|
|
"result": result or {},
|
|
"rebuild": rebuild
|
|
or {
|
|
"suppressed": False,
|
|
"rollback_order": order,
|
|
"status": "rebuildable" if node_rebuildable else "unsupported",
|
|
},
|
|
"provenance": provenance_payload,
|
|
"confidence": confidence,
|
|
"rebuildable": node_rebuildable,
|
|
}
|
|
|
|
|
|
def _empty_selection_sets() -> dict[str, list[Any]]:
|
|
return {
|
|
"selected_faces": [],
|
|
"selected_edges": [],
|
|
"selected_contours": [],
|
|
}
|
|
|
|
|
|
def _default_references(
|
|
inputs: dict[str, Any], children: list[str]
|
|
) -> dict[str, Any]:
|
|
parent_features = []
|
|
host = inputs.get("host")
|
|
if isinstance(host, str) and host:
|
|
parent_features.append(host)
|
|
return {
|
|
"parent": inputs.get("parent"),
|
|
"sketch": inputs.get("sketch"),
|
|
"plane": inputs.get("plane"),
|
|
"parent_features": parent_features,
|
|
"child_features": list(children),
|
|
}
|
|
|
|
|
|
def _default_definition(
|
|
solidworks_type: str,
|
|
display_name: str,
|
|
inputs: dict[str, Any],
|
|
operation_spec: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
if solidworks_type in {"Part", "HistoryFolder"}:
|
|
return {"feature_type": solidworks_type, "name": display_name}
|
|
if solidworks_type in {"RefPlane", "RefAxis", "OriginProfileFeature"}:
|
|
return {
|
|
"feature_type": solidworks_type,
|
|
"name": display_name,
|
|
"standard_reference_geometry": True,
|
|
}
|
|
return {
|
|
"feature_type": solidworks_type,
|
|
"name": display_name,
|
|
"sketch": inputs.get("sketch"),
|
|
"parameters_defined": bool(operation_spec),
|
|
}
|
|
|
|
|
|
def _display_name(item: dict[str, Any], fallback: str) -> str:
|
|
for key in ("display_name", "label", "name", "title"):
|
|
value = item.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return fallback
|
|
|
|
|
|
def _parameter_refs(value: Any) -> set[str]:
|
|
refs: set[str] = set()
|
|
if isinstance(value, dict):
|
|
parameter = value.get("parameter")
|
|
if isinstance(parameter, str):
|
|
refs.add(parameter)
|
|
for child in value.values():
|
|
refs.update(_parameter_refs(child))
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
refs.update(_parameter_refs(child))
|
|
return refs
|
|
|
|
|
|
def _expression_parameter_refs(value: Any, candidates: set[str]) -> set[str]:
|
|
refs: set[str] = set()
|
|
if isinstance(value, dict):
|
|
expression = value.get("expression")
|
|
if isinstance(expression, str):
|
|
for candidate in candidates:
|
|
if re.search(rf"\b{re.escape(candidate)}\b", expression):
|
|
refs.add(candidate)
|
|
for child in value.values():
|
|
refs.update(_expression_parameter_refs(child, candidates))
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
refs.update(_expression_parameter_refs(child, candidates))
|
|
return refs
|
|
|
|
|
|
def _value_for_ref(value: Any, parameters: dict[str, Any]) -> Any:
|
|
if isinstance(value, dict) and isinstance(value.get("parameter"), str):
|
|
parameter = parameters.get(value["parameter"], {})
|
|
if isinstance(parameter, dict):
|
|
return parameter.get("value")
|
|
if isinstance(value, dict) and isinstance(value.get("expression"), str):
|
|
return value.get("expression")
|
|
return value
|
|
|
|
|
|
def _rebuild_value(value: Any, default: Any = None) -> Any:
|
|
if value is None:
|
|
return default
|
|
return copy.deepcopy(value)
|
|
|
|
|
|
def _parameter_display_name(name: str, payload: dict[str, Any]) -> str:
|
|
display = payload.get("display_name") or payload.get("label") or payload.get("name")
|
|
if isinstance(display, str) and display.strip():
|
|
return display.strip()
|
|
replacements = {
|
|
"outer": "外",
|
|
"inner": "内",
|
|
"radius": "半径",
|
|
"diameter": "直径",
|
|
"height": "高度",
|
|
"depth": "深度",
|
|
"width": "宽度",
|
|
"thickness": "厚度",
|
|
"hole": "孔",
|
|
"count": "数量",
|
|
"pitch": "分布圆",
|
|
"bore": "中心孔",
|
|
}
|
|
words = []
|
|
for part in re.split(r"[_\s-]+", name):
|
|
words.append(replacements.get(part.lower(), part))
|
|
return "".join(words)
|
|
|
|
|
|
def _feature_parameters(
|
|
feature: dict[str, Any], parameters: dict[str, Any], explicit_refs: set[str] | None = None
|
|
) -> list[dict[str, Any]]:
|
|
refs = set(explicit_refs if explicit_refs is not None else _parameter_refs(feature))
|
|
refs.update(_expression_parameter_refs(feature, set(parameters)))
|
|
refs = sorted(refs)
|
|
result: list[dict[str, Any]] = []
|
|
for name in refs:
|
|
source = parameters.get(name, {})
|
|
source_payload = source if isinstance(source, dict) else {"value": source}
|
|
result.append(
|
|
{
|
|
"id": f"param:{name}",
|
|
"name": name,
|
|
"display_name": _parameter_display_name(name, source_payload),
|
|
"value": source_payload.get("value"),
|
|
"default_value": source_payload.get(
|
|
"default_value", source_payload.get("value")
|
|
),
|
|
"unit": source_payload.get("unit"),
|
|
"binding_id": name,
|
|
"editable": bool(source_payload.get("editable", True)),
|
|
"edit_state": source_payload.get("edit_state"),
|
|
"source_pointer": _json_pointer(
|
|
["semantic_layer", "parameters", name]
|
|
),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _sketch_profile_for_feature(
|
|
feature: dict[str, Any], parameters: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
operation = feature.get("operation")
|
|
if operation in {"extrude_circle", "add_cylinder"}:
|
|
return {
|
|
"type": "circle",
|
|
"center": _rebuild_value(feature.get("center", [0, 0, 0])),
|
|
"radius": _rebuild_value(feature.get("radius")),
|
|
}
|
|
if operation == "extrude_rectangle":
|
|
return {
|
|
"type": "rectangle",
|
|
"center": _rebuild_value(feature.get("center", [0, 0, 0])),
|
|
"width": _rebuild_value(feature.get("width")),
|
|
"depth": _rebuild_value(feature.get("depth")),
|
|
}
|
|
if operation == "through_hole":
|
|
return {
|
|
"type": "circle",
|
|
"center": _rebuild_value(feature.get("center", [0, 0, 0])),
|
|
"diameter": _rebuild_value(feature.get("diameter")),
|
|
}
|
|
if operation == "polar_hole_pattern":
|
|
return {
|
|
"type": "circle",
|
|
"center": {
|
|
"x": {
|
|
"expression": "pitch_diameter / 2",
|
|
"value": _rebuild_value(feature.get("pitch_diameter")),
|
|
},
|
|
"y": 0,
|
|
"z": 0,
|
|
},
|
|
"diameter": _rebuild_value(feature.get("diameter")),
|
|
}
|
|
return {"type": "unknown"}
|
|
|
|
|
|
def _feature_ref(feature: dict[str, Any], key: str, default: Any = None) -> Any:
|
|
return _rebuild_value(feature.get(key), default)
|
|
|
|
|
|
def _operation_spec_for_feature(
|
|
feature: dict[str, Any],
|
|
sw_type: str,
|
|
*,
|
|
sketch_id: str | None,
|
|
seed_feature_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
operation = str(feature.get("operation") or "unsupported")
|
|
axis = _feature_ref(feature, "axis", "primary_axis")
|
|
if sw_type == "BossExtrude":
|
|
return {
|
|
"kind": "boss_extrude",
|
|
"designir_operation": operation,
|
|
"profile": _sketch_profile_for_feature(feature, {}),
|
|
"sketch": sketch_id,
|
|
"depth": _feature_ref(feature, "height"),
|
|
"direction": axis,
|
|
"center": _feature_ref(feature, "center", [0, 0, 0]),
|
|
"anchor": _feature_ref(feature, "anchor", "base_center"),
|
|
"end_condition": "Blind",
|
|
"merge_result": True,
|
|
}
|
|
if sw_type == "CutExtrude":
|
|
return {
|
|
"kind": "cut_extrude",
|
|
"designir_operation": "through_hole",
|
|
"profile": _sketch_profile_for_feature(feature, {}),
|
|
"sketch": sketch_id,
|
|
"diameter": _feature_ref(feature, "diameter"),
|
|
"center": _feature_ref(feature, "center", [0, 0, 0]),
|
|
"direction": axis,
|
|
"host": _feature_ref(feature, "host"),
|
|
"end_condition": "ThroughAll",
|
|
}
|
|
if sw_type == "CircularPattern":
|
|
return {
|
|
"kind": "circular_pattern",
|
|
"designir_operation": "polar_hole_pattern",
|
|
"seed_feature": seed_feature_id,
|
|
"count": _feature_ref(feature, "count"),
|
|
"diameter": _feature_ref(feature, "diameter"),
|
|
"pitch_diameter": _feature_ref(feature, "pitch_diameter"),
|
|
"axis": axis,
|
|
"host": _feature_ref(feature, "host"),
|
|
"total_angle": 360,
|
|
"equal_spacing": True,
|
|
}
|
|
return {
|
|
"kind": "unsupported",
|
|
"designir_operation": operation,
|
|
}
|
|
|
|
|
|
def _profile_entities(profile: dict[str, Any]) -> list[dict[str, Any]]:
|
|
kind = profile.get("type")
|
|
if kind == "circle":
|
|
entity = {
|
|
"id": "entity:circle1",
|
|
"type": "Circle",
|
|
"center": _rebuild_value(profile.get("center", [0, 0, 0])),
|
|
}
|
|
if profile.get("radius") is not None:
|
|
entity["radius"] = _rebuild_value(profile.get("radius"))
|
|
if profile.get("diameter") is not None:
|
|
entity["diameter"] = _rebuild_value(profile.get("diameter"))
|
|
return [entity]
|
|
if kind == "rectangle":
|
|
return [
|
|
{
|
|
"id": "entity:rectangle1",
|
|
"type": "CenterRectangle",
|
|
"center": _rebuild_value(profile.get("center", [0, 0, 0])),
|
|
"width": _rebuild_value(profile.get("width")),
|
|
"height": _rebuild_value(profile.get("depth")),
|
|
}
|
|
]
|
|
return []
|
|
|
|
|
|
def _dimensions_from_parameters(
|
|
owner_display_name: str, parameters: list[dict[str, Any]]
|
|
) -> list[dict[str, Any]]:
|
|
dimensions: list[dict[str, Any]] = []
|
|
for index, parameter in enumerate(parameters, start=1):
|
|
if not isinstance(parameter, dict):
|
|
continue
|
|
binding_id = parameter.get("binding_id") or parameter.get("name")
|
|
dimensions.append(
|
|
{
|
|
"id": f"dimension:{binding_id or index}",
|
|
"name": f"D{index}@{owner_display_name}",
|
|
"display_name": parameter.get("display_name") or str(binding_id or f"D{index}"),
|
|
"value": parameter.get("value"),
|
|
"default_value": parameter.get("default_value"),
|
|
"unit": parameter.get("unit"),
|
|
"parameter_binding": binding_id,
|
|
"driven": False,
|
|
}
|
|
)
|
|
return dimensions
|
|
|
|
|
|
def _sketch_definition(
|
|
feature: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
*,
|
|
plane: str,
|
|
) -> dict[str, Any]:
|
|
profile = _sketch_profile_for_feature(feature, parameters)
|
|
return {
|
|
"feature_type": "ProfileFeature",
|
|
"sketch_type": "2DProfile",
|
|
"plane": plane,
|
|
"profile": profile,
|
|
"entities": _profile_entities(profile),
|
|
"relations": [],
|
|
"fully_defined": False,
|
|
}
|
|
|
|
|
|
def _solidworks_definition_for_feature(
|
|
sw_type: str,
|
|
display_name: str,
|
|
operation_spec: dict[str, Any],
|
|
*,
|
|
sketch_id: str | None,
|
|
seed_feature_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if sw_type == "BossExtrude":
|
|
return {
|
|
"feature_type": "BossExtrude",
|
|
"name": display_name,
|
|
"sketch": sketch_id,
|
|
"end_condition": operation_spec.get("end_condition", "Blind"),
|
|
"depth": _rebuild_value(operation_spec.get("depth")),
|
|
"direction": _rebuild_value(operation_spec.get("direction"), "primary_axis"),
|
|
"merge_result": bool(operation_spec.get("merge_result", True)),
|
|
"thin_feature": False,
|
|
"draft": {"enabled": False, "angle": None},
|
|
}
|
|
if sw_type == "CutExtrude":
|
|
return {
|
|
"feature_type": "CutExtrude",
|
|
"name": display_name,
|
|
"sketch": sketch_id,
|
|
"end_condition": operation_spec.get("end_condition", "ThroughAll"),
|
|
"depth": _rebuild_value(operation_spec.get("depth")),
|
|
"direction": _rebuild_value(operation_spec.get("direction"), "primary_axis"),
|
|
"flip_side_to_cut": False,
|
|
"draft": {"enabled": False, "angle": None},
|
|
}
|
|
if sw_type == "CircularPattern":
|
|
return {
|
|
"feature_type": "CircularPattern",
|
|
"name": display_name,
|
|
"seed_features": [seed_feature_id] if seed_feature_id else [],
|
|
"axis": _rebuild_value(operation_spec.get("axis"), "primary_axis"),
|
|
"instance_count": _rebuild_value(operation_spec.get("count")),
|
|
"total_angle": operation_spec.get("total_angle", 360),
|
|
"equal_spacing": bool(operation_spec.get("equal_spacing", True)),
|
|
"geometry_pattern": False,
|
|
}
|
|
return {
|
|
"feature_type": sw_type,
|
|
"name": display_name,
|
|
"supported_definition": False,
|
|
}
|
|
|
|
|
|
def _sketch_node(
|
|
*,
|
|
sketch_index: int,
|
|
feature: dict[str, Any],
|
|
parameters: dict[str, Any],
|
|
source_pointer: str,
|
|
order: int,
|
|
) -> dict[str, Any]:
|
|
feature_id = _slug(feature.get("id"), f"feature_{sketch_index}")
|
|
sketch_id = f"sketch:{feature_id}"
|
|
plane = feature.get("plane") or feature.get("sketch_plane") or "Top Plane"
|
|
feature_parameters = _feature_parameters(feature, parameters)
|
|
return _node(
|
|
node_id=sketch_id,
|
|
order=order,
|
|
solidworks_type="ProfileFeature",
|
|
display_name=f"{SKETCH_PREFIX}{sketch_index}",
|
|
english_name=f"Sketch{sketch_index}",
|
|
inputs={"plane": plane},
|
|
parameters=feature_parameters,
|
|
definition=_sketch_definition(feature, parameters, plane=plane),
|
|
dimensions=_dimensions_from_parameters(f"{SKETCH_PREFIX}{sketch_index}", feature_parameters),
|
|
references={
|
|
"parent": "sw:history",
|
|
"sketch": None,
|
|
"plane": plane,
|
|
"parent_features": [],
|
|
"child_features": [],
|
|
},
|
|
selection_sets=_empty_selection_sets(),
|
|
operation_spec={
|
|
"kind": "sketch",
|
|
"profile": _sketch_profile_for_feature(feature, parameters),
|
|
"source_pointer": source_pointer,
|
|
},
|
|
result={"profile_available": True},
|
|
provenance="generated_from_designir",
|
|
confidence=feature.get("confidence", 1.0),
|
|
rebuildable=True,
|
|
)
|
|
|
|
|
|
def _surface_summary(surface_layer: Any) -> dict[str, Any]:
|
|
if not isinstance(surface_layer, dict):
|
|
return {
|
|
"solid_count": 0,
|
|
"free_shell_count": 0,
|
|
"vertex_count": 0,
|
|
"surface_vocabulary": [],
|
|
"curve_vocabulary": [],
|
|
}
|
|
solids = surface_layer.get("solids")
|
|
free_shells = surface_layer.get("free_shells")
|
|
vertices = surface_layer.get("vertices")
|
|
return {
|
|
"solid_count": len(solids) if isinstance(solids, list) else 0,
|
|
"free_shell_count": len(free_shells) if isinstance(free_shells, list) else 0,
|
|
"vertex_count": len(vertices) if isinstance(vertices, list) else 0,
|
|
"surface_vocabulary": surface_layer.get("surface_vocabulary", []),
|
|
"curve_vocabulary": surface_layer.get("curve_vocabulary", []),
|
|
}
|
|
|
|
|
|
def _encode_payload(payload: dict[str, Any]) -> str:
|
|
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
return base64.b64encode(gzip.compress(raw)).decode("ascii")
|
|
|
|
|
|
def _decode_payload(encoded: str) -> dict[str, Any]:
|
|
try:
|
|
raw = gzip.decompress(base64.b64decode(encoded.encode("ascii")))
|
|
decoded = json.loads(raw.decode("utf-8"))
|
|
except Exception as exc: # pragma: no cover - exact exception varies by zlib/base64
|
|
raise FeatureTreeError(f"Cannot decode embedded replay payload: {exc}") from exc
|
|
if not isinstance(decoded, dict):
|
|
raise FeatureTreeError("Embedded replay payload must be a JSON object")
|
|
return decoded
|
|
|
|
|
|
def _ordered_features(semantic: dict[str, Any]) -> list[dict[str, Any]]:
|
|
features = semantic.get("features", [])
|
|
if isinstance(features, dict):
|
|
normalized = []
|
|
for key, value in features.items():
|
|
if isinstance(value, dict):
|
|
normalized.append({"id": key, **value})
|
|
return normalized
|
|
return [item for item in features if isinstance(item, dict)] if isinstance(features, list) else []
|
|
|
|
|
|
def _base_tree(
|
|
payload: dict[str, Any],
|
|
*,
|
|
designir_path: str | None,
|
|
step_path: str | None,
|
|
parameters_path: str | None,
|
|
backend: str | None,
|
|
compiled: bool,
|
|
validated: bool,
|
|
) -> dict[str, Any]:
|
|
model_id = payload.get("model_id") or "part"
|
|
history_kind = _history_kind(payload)
|
|
units = payload.get("units") or "mm"
|
|
root = _node(
|
|
node_id="sw:part",
|
|
order=0,
|
|
solidworks_type="Part",
|
|
display_name=str(model_id),
|
|
english_name=str(model_id),
|
|
children=["sw:history"],
|
|
inputs={},
|
|
operation_spec={"document_type": "part"},
|
|
result={"units": units},
|
|
provenance={"source": "generated_from_designir", "history_kind": history_kind},
|
|
confidence=1.0,
|
|
rebuildable=True,
|
|
)
|
|
nodes = [root]
|
|
for order, (node_id, sw_type, display, english) in enumerate(STANDARD_NODES, start=1):
|
|
parent_children = []
|
|
if node_id == "sw:history":
|
|
parent_children = ["sw:origin_folder"]
|
|
if node_id == "sw:origin_folder":
|
|
parent_children = ["sw:front_plane", "sw:top_plane", "sw:right_plane", "sw:origin"]
|
|
nodes.append(
|
|
_node(
|
|
node_id=node_id,
|
|
order=order,
|
|
solidworks_type=sw_type,
|
|
display_name=display,
|
|
english_name=english,
|
|
children=parent_children,
|
|
inputs={
|
|
"parent": (
|
|
"sw:part"
|
|
if node_id == "sw:history"
|
|
else "sw:history"
|
|
if node_id == "sw:origin_folder"
|
|
else "sw:origin_folder"
|
|
)
|
|
},
|
|
operation_spec={"standard_reference_geometry": True},
|
|
result={},
|
|
provenance="solidworks_standard_tree",
|
|
confidence=1.0,
|
|
rebuildable=True,
|
|
)
|
|
)
|
|
return {
|
|
"schema_version": SCHEMA_VERSION,
|
|
"tree_kind": TREE_KIND,
|
|
"model": {
|
|
"model_id": model_id,
|
|
"family": payload.get("family"),
|
|
"units": units,
|
|
"document_type": "part",
|
|
},
|
|
"source": {
|
|
"authority": "designir-3.0",
|
|
"designir_path": designir_path,
|
|
"step_path": step_path,
|
|
"parameters_path": parameters_path,
|
|
"reconstruction_mode": payload.get("reconstruction_mode"),
|
|
"history_kind": history_kind,
|
|
"backend": backend or payload.get("backend_hint"),
|
|
"claims_original_solidworks_history": False,
|
|
},
|
|
"feature_manager": {
|
|
"root_id": "sw:part",
|
|
"standard_root": {
|
|
"part": "sw:part",
|
|
"history_folder": "sw:history",
|
|
"origin_folder": "sw:origin_folder",
|
|
"front_plane": "sw:front_plane",
|
|
"top_plane": "sw:top_plane",
|
|
"right_plane": "sw:right_plane",
|
|
"origin": "sw:origin",
|
|
},
|
|
"nodes": nodes,
|
|
},
|
|
"rebuild_contract": {},
|
|
"validation": {
|
|
"structure": {"status": "pending", "errors": []},
|
|
"replay": {
|
|
"status": "not_run",
|
|
"compiled_by_generation_pipeline": bool(compiled),
|
|
},
|
|
"geometry": {
|
|
"status": "external_pipeline_validated" if validated else "not_run",
|
|
"step_path": step_path,
|
|
},
|
|
"brep": {"status": "not_run"},
|
|
"feature_recognition": {
|
|
"status": "not_run",
|
|
"coverage": None,
|
|
"unrecognized_reasons": [],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _append_semantic_nodes(tree: dict[str, Any], payload: dict[str, Any]) -> None:
|
|
semantic = payload["semantic_layer"]
|
|
parameters = semantic.get("parameters", {})
|
|
if not isinstance(parameters, dict):
|
|
parameters = {}
|
|
nodes = tree["feature_manager"]["nodes"]
|
|
history_node = next(node for node in nodes if node["id"] == "sw:history")
|
|
history_children = history_node["children"]
|
|
counters = {
|
|
"sketch": 0,
|
|
"BossExtrude": 0,
|
|
"CutExtrude": 0,
|
|
"Revolve": 0,
|
|
"RevolvedCut": 0,
|
|
"HoleWizard": 0,
|
|
"Fillet": 0,
|
|
"Chamfer": 0,
|
|
"LinearPattern": 0,
|
|
"CircularPattern": 0,
|
|
"MirrorPattern": 0,
|
|
"UnsupportedFeature": 0,
|
|
}
|
|
order = max(node["order"] for node in nodes) + 1
|
|
sketch_required_ops = {
|
|
"extrude_circle",
|
|
"extrude_rectangle",
|
|
"add_cylinder",
|
|
"through_hole",
|
|
"polar_hole_pattern",
|
|
"revolve",
|
|
"revolved_cut",
|
|
"hole_wizard",
|
|
}
|
|
last_body_node: str | None = None
|
|
for index, feature in enumerate(_ordered_features(semantic), start=1):
|
|
operation = str(feature.get("operation") or "unsupported")
|
|
feature_source_pointer = _json_pointer(["semantic_layer", "features", index - 1])
|
|
host_node_id = (
|
|
f"feature:{_slug(feature.get('host'), 'host')}"
|
|
if isinstance(feature.get("host"), str) and feature.get("host")
|
|
else last_body_node
|
|
)
|
|
sketch_id: str | None = None
|
|
if operation in sketch_required_ops:
|
|
counters["sketch"] += 1
|
|
sketch = _sketch_node(
|
|
sketch_index=counters["sketch"],
|
|
feature=feature,
|
|
parameters=parameters,
|
|
source_pointer=feature_source_pointer,
|
|
order=order,
|
|
)
|
|
order += 1
|
|
nodes.append(sketch)
|
|
history_children.append(sketch["id"])
|
|
sketch_id = sketch["id"]
|
|
|
|
sw_type = DESIGNIR_TO_SW.get(operation, "UnsupportedFeature")
|
|
seed_id: str | None = None
|
|
if operation == "polar_hole_pattern":
|
|
seed_id = f"feature:{_slug(feature.get('id'), f'feature_{index}')}:seed_cut"
|
|
counters["CutExtrude"] += 1
|
|
seed_display_name = f"{DISPLAY_PREFIX['CutExtrude']}{counters['CutExtrude']}"
|
|
seed_parameters = _feature_parameters(feature, parameters)
|
|
seed_operation_spec = _operation_spec_for_feature(
|
|
feature,
|
|
"CutExtrude",
|
|
sketch_id=sketch_id,
|
|
) | {
|
|
"kind": "cut_extrude_seed",
|
|
"participates_in": "CircularPattern",
|
|
"source_pointer": feature_source_pointer,
|
|
}
|
|
seed_node = _node(
|
|
node_id=seed_id,
|
|
order=order,
|
|
solidworks_type="CutExtrude",
|
|
display_name=seed_display_name,
|
|
english_name=f"Cut-Extrude{counters['CutExtrude']}",
|
|
children=[],
|
|
inputs={"sketch": sketch_id, "host": host_node_id},
|
|
parameters=seed_parameters,
|
|
definition=_solidworks_definition_for_feature(
|
|
"CutExtrude",
|
|
seed_display_name,
|
|
seed_operation_spec,
|
|
sketch_id=sketch_id,
|
|
),
|
|
dimensions=_dimensions_from_parameters(seed_display_name, seed_parameters),
|
|
references={
|
|
"parent": "sw:history",
|
|
"sketch": sketch_id,
|
|
"plane": None,
|
|
"parent_features": [host_node_id] if host_node_id else [],
|
|
"child_features": [],
|
|
},
|
|
selection_sets=_empty_selection_sets(),
|
|
operation_spec=seed_operation_spec,
|
|
result={"creates_seed_for": "CircularPattern"},
|
|
provenance="generated_from_designir",
|
|
confidence=feature.get("confidence", 1.0),
|
|
rebuildable=True,
|
|
)
|
|
nodes.append(seed_node)
|
|
history_children.append(seed_id)
|
|
order += 1
|
|
if sketch_id:
|
|
next(node for node in nodes if node["id"] == sketch_id)["children"].append(seed_id)
|
|
|
|
counters[sw_type] = counters.get(sw_type, 0) + 1
|
|
number = counters[sw_type]
|
|
prefix = DISPLAY_PREFIX.get(sw_type, "特征")
|
|
feature_id = f"feature:{_slug(feature.get('id'), f'feature_{index}')}"
|
|
feature_display_name = f"{prefix}{number}"
|
|
feature_parameters = _feature_parameters(feature, parameters)
|
|
feature_operation_spec = _operation_spec_for_feature(
|
|
feature,
|
|
sw_type,
|
|
sketch_id=sketch_id,
|
|
seed_feature_id=seed_id,
|
|
) | {"source_pointer": feature_source_pointer}
|
|
feature_node = _node(
|
|
node_id=feature_id,
|
|
order=order,
|
|
solidworks_type=sw_type,
|
|
display_name=feature_display_name,
|
|
english_name=(
|
|
"Boss-Extrude"
|
|
if sw_type == "BossExtrude"
|
|
else "Cut-Extrude"
|
|
if sw_type == "CutExtrude"
|
|
else sw_type
|
|
)
|
|
+ str(number),
|
|
children=[],
|
|
inputs={
|
|
"sketch": sketch_id,
|
|
"host": host_node_id if sw_type in {"CutExtrude", "Fillet", "Chamfer"} else None,
|
|
},
|
|
parameters=feature_parameters,
|
|
definition=_solidworks_definition_for_feature(
|
|
sw_type,
|
|
feature_display_name,
|
|
feature_operation_spec,
|
|
sketch_id=sketch_id,
|
|
seed_feature_id=seed_id,
|
|
),
|
|
dimensions=_dimensions_from_parameters(feature_display_name, feature_parameters),
|
|
references={
|
|
"parent": "sw:history",
|
|
"sketch": sketch_id,
|
|
"plane": None,
|
|
"parent_features": (
|
|
[host_node_id]
|
|
if host_node_id and sw_type in {"CutExtrude", "Fillet", "Chamfer"}
|
|
else [seed_id]
|
|
if seed_id and sw_type == "CircularPattern"
|
|
else []
|
|
),
|
|
"child_features": [],
|
|
},
|
|
selection_sets=_empty_selection_sets(),
|
|
operation_spec=feature_operation_spec,
|
|
result={"operation": "add" if sw_type == "BossExtrude" else "modify"},
|
|
provenance={
|
|
"source": "generated_from_designir",
|
|
"original_history_recovered": bool(
|
|
feature.get("original_history_recovered", False)
|
|
),
|
|
},
|
|
confidence=feature.get("confidence", 1.0),
|
|
rebuildable=sw_type != "UnsupportedFeature",
|
|
)
|
|
nodes.append(feature_node)
|
|
history_children.append(feature_id)
|
|
if sketch_id:
|
|
next(node for node in nodes if node["id"] == sketch_id)["children"].append(feature_id)
|
|
if sw_type == "BossExtrude":
|
|
last_body_node = feature_id
|
|
order += 1
|
|
|
|
tree["rebuild_contract"] = {
|
|
"kind": "feature_manager_native",
|
|
"entrypoint": "feature_tree.compile_feature_manager",
|
|
"backend": tree["source"].get("backend") or payload.get("backend_hint") or "build123d",
|
|
}
|
|
tree["validation"]["feature_recognition"] = {
|
|
"status": "semantic_authority",
|
|
"coverage": 1.0,
|
|
"unrecognized_reasons": [],
|
|
}
|
|
|
|
|
|
def _append_imported_feature(tree: dict[str, Any], payload: dict[str, Any]) -> None:
|
|
nodes = tree["feature_manager"]["nodes"]
|
|
history_node = next(node for node in nodes if node["id"] == "sw:history")
|
|
history_children = history_node["children"]
|
|
encoded = _encode_payload(payload)
|
|
digest = hashlib.sha256(encoded.encode("ascii")).hexdigest()
|
|
summary = _surface_summary(payload.get("surface_layer"))
|
|
imported = _node(
|
|
node_id="feature:Imported1",
|
|
order=max(node["order"] for node in nodes) + 1,
|
|
solidworks_type="ImportedFeature",
|
|
display_name="导入1",
|
|
english_name="Imported1",
|
|
children=[],
|
|
inputs={"source": "embedded_surfaceir"},
|
|
parameters=[],
|
|
definition={
|
|
"feature_type": "ImportedFeature",
|
|
"name": "导入1",
|
|
"imported_body": True,
|
|
"native_history_recovered": False,
|
|
"surface_body_source": "SurfaceIR",
|
|
},
|
|
dimensions=[],
|
|
references={
|
|
"parent": "sw:history",
|
|
"sketch": None,
|
|
"plane": None,
|
|
"parent_features": [],
|
|
"child_features": [],
|
|
},
|
|
selection_sets=_empty_selection_sets(),
|
|
operation_spec={
|
|
"kind": "surfaceir_import",
|
|
"payload_encoding": "gzip+base64+json",
|
|
"payload_sha256": digest,
|
|
"payload": encoded,
|
|
"surface_summary": summary,
|
|
},
|
|
result={"geometry_source": "SurfaceIR", **summary},
|
|
provenance={
|
|
"source": "inferred_from_step",
|
|
"original_history_recovered": False,
|
|
"note": "STEP does not carry authoritative native CAD feature history; this is a SolidWorks-style imported feature.",
|
|
},
|
|
confidence=1.0,
|
|
rebuildable=True,
|
|
)
|
|
nodes.append(imported)
|
|
history_children.append(imported["id"])
|
|
tree["rebuild_contract"] = {
|
|
"kind": "surfaceir_imported_feature",
|
|
"entrypoint": "surfaceir_pipeline.build_surfaceir",
|
|
"payload_node": imported["id"],
|
|
"payload_encoding": "gzip+base64+json",
|
|
"payload_sha256": digest,
|
|
}
|
|
tree["validation"]["feature_recognition"] = {
|
|
"status": "fallback_imported_feature",
|
|
"coverage": 0.0,
|
|
"unrecognized_reasons": [
|
|
"Uploaded STEP does not provide native SolidWorks FeatureManager history.",
|
|
"Native feature decomposition is only publishable after independent replay and geometry validation.",
|
|
],
|
|
}
|
|
|
|
|
|
def _backfill_reference_children(tree: dict[str, Any]) -> None:
|
|
nodes = _nodes_by_id(tree)
|
|
for node in nodes.values():
|
|
references = node.setdefault("references", _default_references(node.get("inputs", {}), node.get("children", [])))
|
|
child_features = references.setdefault("child_features", [])
|
|
for child_id in node.get("children", []):
|
|
if child_id not in child_features:
|
|
child_features.append(child_id)
|
|
for node in nodes.values():
|
|
node_id = node.get("id")
|
|
references = node.get("references", {})
|
|
sketch_id = references.get("sketch")
|
|
if isinstance(sketch_id, str) and sketch_id in nodes:
|
|
sketch_children = nodes[sketch_id].setdefault("references", {}).setdefault("child_features", [])
|
|
if node_id not in sketch_children:
|
|
sketch_children.append(node_id)
|
|
for parent_id in references.get("parent_features", []):
|
|
if isinstance(parent_id, str) and parent_id in nodes:
|
|
parent_children = nodes[parent_id].setdefault("references", {}).setdefault("child_features", [])
|
|
if node_id not in parent_children:
|
|
parent_children.append(node_id)
|
|
|
|
|
|
def validate_feature_tree(tree: dict[str, Any]) -> dict[str, Any]:
|
|
errors: list[str] = []
|
|
if not isinstance(tree, dict):
|
|
raise FeatureTreeError("Feature tree must be a JSON object")
|
|
if tree.get("schema_version") != SCHEMA_VERSION:
|
|
errors.append("schema_version must be 1.0")
|
|
if tree.get("tree_kind") != TREE_KIND:
|
|
errors.append(f"tree_kind must be {TREE_KIND}")
|
|
manager = tree.get("feature_manager")
|
|
nodes = manager.get("nodes") if isinstance(manager, dict) else None
|
|
if not isinstance(nodes, list) or not nodes:
|
|
errors.append("feature_manager.nodes must be a non-empty array")
|
|
return {"valid": False, "errors": errors}
|
|
|
|
ids: set[str] = set()
|
|
display_names: set[str] = set()
|
|
order_values: set[int] = set()
|
|
required = {
|
|
"id",
|
|
"order",
|
|
"solidworks_type",
|
|
"display_name",
|
|
"english_name",
|
|
"children",
|
|
"inputs",
|
|
"parameters",
|
|
"definition",
|
|
"dimensions",
|
|
"references",
|
|
"selection_sets",
|
|
"operation_spec",
|
|
"result",
|
|
"rebuild",
|
|
"provenance",
|
|
"confidence",
|
|
"rebuildable",
|
|
}
|
|
for node in nodes:
|
|
if not isinstance(node, dict):
|
|
errors.append("Every feature_manager node must be an object")
|
|
continue
|
|
missing = sorted(required - set(node))
|
|
if missing:
|
|
errors.append(f"{node.get('id', '<unknown>')} missing fields: {', '.join(missing)}")
|
|
node_id = node.get("id")
|
|
if not isinstance(node_id, str) or not node_id:
|
|
errors.append("Every node requires a non-empty id")
|
|
elif node_id in ids:
|
|
errors.append(f"Duplicate node id: {node_id}")
|
|
else:
|
|
ids.add(node_id)
|
|
order = node.get("order")
|
|
if not isinstance(order, int):
|
|
errors.append(f"{node_id} order must be an integer")
|
|
elif order in order_values:
|
|
errors.append(f"Duplicate node order: {order}")
|
|
else:
|
|
order_values.add(order)
|
|
sw_type = node.get("solidworks_type")
|
|
if sw_type not in SUPPORTED_SW_TYPES:
|
|
errors.append(f"{node_id} unsupported solidworks_type: {sw_type}")
|
|
display = node.get("display_name")
|
|
if isinstance(display, str) and display:
|
|
if display in display_names and display not in {"原点"}:
|
|
errors.append(f"Duplicate display_name: {display}")
|
|
display_names.add(display)
|
|
if not isinstance(node.get("children"), list):
|
|
errors.append(f"{node_id} children must be an array")
|
|
if not isinstance(node.get("parameters"), list):
|
|
errors.append(f"{node_id} parameters must be an array")
|
|
if not isinstance(node.get("definition"), dict):
|
|
errors.append(f"{node_id} definition must be an object")
|
|
if not isinstance(node.get("dimensions"), list):
|
|
errors.append(f"{node_id} dimensions must be an array")
|
|
if not isinstance(node.get("references"), dict):
|
|
errors.append(f"{node_id} references must be an object")
|
|
if not isinstance(node.get("selection_sets"), dict):
|
|
errors.append(f"{node_id} selection_sets must be an object")
|
|
if not isinstance(node.get("rebuild"), dict):
|
|
errors.append(f"{node_id} rebuild must be an object")
|
|
for parameter in node.get("parameters", []):
|
|
if not isinstance(parameter, dict):
|
|
errors.append(f"{node_id} parameter entries must be objects")
|
|
continue
|
|
if not parameter.get("binding_id"):
|
|
errors.append(f"{node_id} parameter missing binding_id")
|
|
|
|
for node in nodes:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
for child_id in node.get("children", []):
|
|
if child_id not in ids:
|
|
errors.append(f"{node.get('id')} references missing child {child_id}")
|
|
|
|
graph = {
|
|
node["id"]: [child for child in node.get("children", []) if child in ids]
|
|
for node in nodes
|
|
if isinstance(node, dict) and isinstance(node.get("id"), str)
|
|
}
|
|
visiting: set[str] = set()
|
|
visited: set[str] = set()
|
|
|
|
def visit(node_id: str) -> None:
|
|
if node_id in visiting:
|
|
errors.append(f"Cycle detected at {node_id}")
|
|
return
|
|
if node_id in visited:
|
|
return
|
|
visiting.add(node_id)
|
|
for child in graph.get(node_id, []):
|
|
visit(child)
|
|
visiting.remove(node_id)
|
|
visited.add(node_id)
|
|
|
|
root_id = manager.get("root_id") if isinstance(manager, dict) else None
|
|
if root_id not in ids:
|
|
errors.append("feature_manager.root_id is missing or unknown")
|
|
elif isinstance(root_id, str):
|
|
visit(root_id)
|
|
return {"valid": not errors, "errors": errors}
|
|
|
|
|
|
def _nodes_by_id(tree: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
return {
|
|
node["id"]: node
|
|
for node in tree.get("feature_manager", {}).get("nodes", [])
|
|
if isinstance(node, dict) and isinstance(node.get("id"), str)
|
|
}
|
|
|
|
|
|
def _feature_id_from_node(node: dict[str, Any]) -> str:
|
|
raw_id = str(node.get("id") or "")
|
|
if raw_id.startswith("feature:"):
|
|
raw_id = raw_id[len("feature:") :]
|
|
return _slug(raw_id.replace(":seed_cut", "_seed_cut"), "feature")
|
|
|
|
|
|
def _collect_tree_parameters(tree: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
parameters: dict[str, dict[str, Any]] = {}
|
|
for node in tree.get("feature_manager", {}).get("nodes", []):
|
|
if not isinstance(node, dict):
|
|
continue
|
|
for parameter in node.get("parameters", []):
|
|
if not isinstance(parameter, dict):
|
|
continue
|
|
name = parameter.get("binding_id") or parameter.get("name")
|
|
if not isinstance(name, str) or not name:
|
|
continue
|
|
existing = parameters.get(name, {})
|
|
parameters[name] = {
|
|
"value": parameter.get("value", existing.get("value")),
|
|
"unit": parameter.get("unit", existing.get("unit", "mm")),
|
|
"editable": bool(parameter.get("editable", existing.get("editable", True))),
|
|
}
|
|
if parameter.get("display_name"):
|
|
parameters[name]["display_name"] = parameter["display_name"]
|
|
if parameter.get("default_value") is not None:
|
|
parameters[name]["default_value"] = parameter["default_value"]
|
|
if parameter.get("edit_state") is not None:
|
|
parameters[name]["edit_state"] = parameter["edit_state"]
|
|
return parameters
|
|
|
|
|
|
def _profile_for_node(
|
|
node: dict[str, Any], nodes: dict[str, dict[str, Any]]
|
|
) -> dict[str, Any]:
|
|
spec = node.get("operation_spec", {})
|
|
profile = spec.get("profile") if isinstance(spec, dict) else None
|
|
if isinstance(profile, dict) and profile.get("type"):
|
|
return copy.deepcopy(profile)
|
|
sketch_id = node.get("inputs", {}).get("sketch")
|
|
sketch = nodes.get(sketch_id) if isinstance(sketch_id, str) else None
|
|
sketch_spec = sketch.get("operation_spec", {}) if isinstance(sketch, dict) else {}
|
|
sketch_profile = sketch_spec.get("profile") if isinstance(sketch_spec, dict) else None
|
|
if isinstance(sketch_profile, dict) and sketch_profile.get("type"):
|
|
return copy.deepcopy(sketch_profile)
|
|
return {"type": "unknown"}
|
|
|
|
|
|
def _normalize_center(value: Any, default: list[Any] | None = None) -> list[Any]:
|
|
if default is None:
|
|
default = [0, 0, 0]
|
|
if not isinstance(value, list):
|
|
return copy.deepcopy(default)
|
|
if len(value) == 2:
|
|
return [copy.deepcopy(value[0]), copy.deepcopy(value[1]), 0]
|
|
if len(value) >= 3:
|
|
return [copy.deepcopy(value[0]), copy.deepcopy(value[1]), copy.deepcopy(value[2])]
|
|
return copy.deepcopy(default)
|
|
|
|
|
|
def _compile_boss_extrude_node(
|
|
node: dict[str, Any], nodes: dict[str, dict[str, Any]]
|
|
) -> dict[str, Any]:
|
|
spec = node.get("operation_spec", {})
|
|
if not isinstance(spec, dict):
|
|
spec = {}
|
|
profile = _profile_for_node(node, nodes)
|
|
operation = str(spec.get("designir_operation") or "")
|
|
if operation not in {"extrude_circle", "add_cylinder", "extrude_rectangle"}:
|
|
operation = "extrude_rectangle" if profile.get("type") == "rectangle" else "extrude_circle"
|
|
feature: dict[str, Any] = {
|
|
"id": _feature_id_from_node(node),
|
|
"operation": operation,
|
|
"height": _rebuild_value(spec.get("depth")),
|
|
"axis": _rebuild_value(spec.get("direction"), "primary_axis"),
|
|
"center": _normalize_center(spec.get("center", profile.get("center"))),
|
|
}
|
|
if spec.get("anchor"):
|
|
feature["anchor"] = spec["anchor"]
|
|
if operation == "extrude_rectangle":
|
|
feature["width"] = _rebuild_value(profile.get("width"))
|
|
feature["depth"] = _rebuild_value(profile.get("depth"))
|
|
else:
|
|
feature["radius"] = _rebuild_value(profile.get("radius"))
|
|
return feature
|
|
|
|
|
|
def _compile_cut_extrude_node(
|
|
node: dict[str, Any], nodes: dict[str, dict[str, Any]]
|
|
) -> dict[str, Any] | None:
|
|
spec = node.get("operation_spec", {})
|
|
if not isinstance(spec, dict):
|
|
spec = {}
|
|
if spec.get("kind") == "cut_extrude_seed":
|
|
return None
|
|
profile = _profile_for_node(node, nodes)
|
|
return {
|
|
"id": _feature_id_from_node(node),
|
|
"operation": "through_hole",
|
|
"diameter": _rebuild_value(spec.get("diameter", profile.get("diameter"))),
|
|
"axis": _rebuild_value(spec.get("direction"), "primary_axis"),
|
|
"center": _normalize_center(spec.get("center", profile.get("center"))),
|
|
"host": _rebuild_value(spec.get("host") or node.get("inputs", {}).get("host")),
|
|
}
|
|
|
|
|
|
def _compile_circular_pattern_node(node: dict[str, Any]) -> dict[str, Any]:
|
|
spec = node.get("operation_spec", {})
|
|
if not isinstance(spec, dict):
|
|
spec = {}
|
|
return {
|
|
"id": _feature_id_from_node(node),
|
|
"operation": "polar_hole_pattern",
|
|
"count": _rebuild_value(spec.get("count")),
|
|
"diameter": _rebuild_value(spec.get("diameter")),
|
|
"pitch_diameter": _rebuild_value(spec.get("pitch_diameter")),
|
|
"axis": _rebuild_value(spec.get("axis"), "primary_axis"),
|
|
"host": _rebuild_value(spec.get("host") or node.get("inputs", {}).get("host")),
|
|
}
|
|
|
|
|
|
def _compile_feature_manager_native(tree: dict[str, Any]) -> dict[str, Any]:
|
|
nodes = _nodes_by_id(tree)
|
|
ordered_nodes = sorted(nodes.values(), key=lambda item: int(item.get("order", 0)))
|
|
features: list[dict[str, Any]] = []
|
|
unsupported: list[str] = []
|
|
for node in ordered_nodes:
|
|
sw_type = node.get("solidworks_type")
|
|
if sw_type in {
|
|
"Part",
|
|
"HistoryFolder",
|
|
"OriginProfileFeature",
|
|
"RefPlane",
|
|
"RefAxis",
|
|
"ProfileFeature",
|
|
}:
|
|
continue
|
|
if sw_type == "BossExtrude":
|
|
features.append(_compile_boss_extrude_node(node, nodes))
|
|
elif sw_type == "CutExtrude":
|
|
cut = _compile_cut_extrude_node(node, nodes)
|
|
if cut is not None:
|
|
features.append(cut)
|
|
elif sw_type == "CircularPattern":
|
|
features.append(_compile_circular_pattern_node(node))
|
|
elif sw_type == "ImportedFeature":
|
|
unsupported.append(f"{node.get('display_name')} must use surfaceir_imported_feature replay")
|
|
else:
|
|
unsupported.append(
|
|
f"{node.get('display_name') or node.get('id')} ({sw_type}) is not supported by the FeatureTreeCompiler"
|
|
)
|
|
if unsupported:
|
|
raise FeatureTreeError("; ".join(unsupported))
|
|
if not features:
|
|
raise FeatureTreeError("FeatureManager tree does not contain rebuildable semantic features")
|
|
|
|
parameters = _collect_tree_parameters(tree)
|
|
editable_parameters = [
|
|
name for name, parameter in parameters.items() if parameter.get("editable", True)
|
|
]
|
|
model = tree.get("model", {})
|
|
source = tree.get("source", {})
|
|
return {
|
|
"schema_version": "3.0",
|
|
"designir_kind": "independent_parametric_cad",
|
|
"model_id": model.get("model_id") or "feature_tree_replay",
|
|
"family": model.get("family") or "general_mechanical_part",
|
|
"units": model.get("units") or "mm",
|
|
"document_status": "geometry_present",
|
|
"reconstruction_mode": "fully_semantic_parametric",
|
|
"authoring_mode": "feature_manager_tree_replay",
|
|
"backend_hint": source.get("backend") or tree.get("rebuild_contract", {}).get("backend") or "build123d",
|
|
"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": {
|
|
"primary_axis": {"kind": "axis", "axis": "z"},
|
|
},
|
|
"parameters": parameters,
|
|
"expressions": {},
|
|
"sketches": [],
|
|
"constraints": [],
|
|
"features": features,
|
|
"patterns": [],
|
|
"attachments": [],
|
|
"construction_stages": [],
|
|
},
|
|
"edit_interface": {
|
|
"semantic_parameters": editable_parameters,
|
|
"surface_parameter_groups": [],
|
|
"modification_levels": ["semantic_feature"],
|
|
"preserved_interfaces": [],
|
|
},
|
|
"validation_contract": {
|
|
"source_independence": True,
|
|
"geometry_checks": ["solid_count", "bounding_box", "volume", "center_of_mass"],
|
|
"edit_checks": ["parameter_perturbation", "constraint_preservation"],
|
|
"invariants": [],
|
|
"perturbations": [],
|
|
"thresholds": {},
|
|
},
|
|
}
|
|
|
|
|
|
def generate_feature_tree(
|
|
designir: dict[str, Any],
|
|
*,
|
|
designir_path: str | None = None,
|
|
step_path: str | None = None,
|
|
parameters_path: str | None = None,
|
|
backend: str | None = None,
|
|
compiled: bool = False,
|
|
validated: bool = False,
|
|
) -> dict[str, Any]:
|
|
payload = _require_designir_3(designir)
|
|
tree = _base_tree(
|
|
payload,
|
|
designir_path=designir_path,
|
|
step_path=step_path,
|
|
parameters_path=parameters_path,
|
|
backend=backend,
|
|
compiled=compiled,
|
|
validated=validated,
|
|
)
|
|
if payload.get("reconstruction_mode") == "surface_parametric":
|
|
_append_imported_feature(tree, payload)
|
|
else:
|
|
_append_semantic_nodes(tree, payload)
|
|
_backfill_reference_children(tree)
|
|
|
|
validation = validate_feature_tree(tree)
|
|
tree["validation"]["structure"] = {
|
|
"status": "valid" if validation["valid"] else "invalid",
|
|
"errors": validation["errors"],
|
|
}
|
|
if not validation["valid"]:
|
|
raise FeatureTreeError("; ".join(validation["errors"]))
|
|
return tree
|
|
|
|
|
|
def designir_from_feature_tree(tree: dict[str, Any]) -> dict[str, Any]:
|
|
validation = validate_feature_tree(tree)
|
|
if not validation["valid"]:
|
|
raise FeatureTreeError("; ".join(validation["errors"]))
|
|
contract = tree.get("rebuild_contract", {})
|
|
if not isinstance(contract, dict):
|
|
raise FeatureTreeError("rebuild_contract must be an object")
|
|
if contract.get("kind") == "feature_manager_native":
|
|
return _compile_feature_manager_native(tree)
|
|
if contract.get("kind") == "surfaceir_imported_feature":
|
|
payload_node_id = contract.get("payload_node")
|
|
nodes = tree.get("feature_manager", {}).get("nodes", [])
|
|
imported = next(
|
|
(
|
|
node
|
|
for node in nodes
|
|
if isinstance(node, dict) and node.get("id") == payload_node_id
|
|
),
|
|
None,
|
|
)
|
|
if not isinstance(imported, dict):
|
|
raise FeatureTreeError("surface rebuild contract references a missing payload node")
|
|
operation_spec = imported.get("operation_spec", {})
|
|
if operation_spec.get("payload_encoding") != "gzip+base64+json":
|
|
raise FeatureTreeError("unsupported imported feature payload encoding")
|
|
return _decode_payload(str(operation_spec.get("payload") or ""))
|
|
raise FeatureTreeError(f"Unsupported rebuild_contract kind: {contract.get('kind')}")
|
|
|
|
|
|
def _load_module(name: str, path: Path) -> Any:
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
if spec is None or spec.loader is None:
|
|
raise FeatureTreeError(f"Cannot load module {path}")
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def compile_feature_tree(
|
|
tree: dict[str, Any],
|
|
output_step: Path,
|
|
*,
|
|
output_designir: Path | None = None,
|
|
backend: str | None = None,
|
|
) -> dict[str, Any]:
|
|
designir = designir_from_feature_tree(tree)
|
|
output_step = output_step.expanduser().resolve()
|
|
output_step.parent.mkdir(parents=True, exist_ok=True)
|
|
if output_designir is not None:
|
|
write_designir(output_designir.expanduser().resolve(), designir)
|
|
|
|
if designir.get("reconstruction_mode") == "surface_parametric":
|
|
surfaceir = _load_module("_feature_tree_surfaceir_pipeline", SCRIPT_DIR / "surfaceir_pipeline.py")
|
|
shape = surfaceir.build_surfaceir(designir)
|
|
surfaceir.export_step_shape(shape, output_step)
|
|
return {
|
|
"valid": True,
|
|
"output_step": str(output_step),
|
|
"output_designir": str(output_designir) if output_designir else None,
|
|
"backend": "surfaceir_occt",
|
|
"replay_kind": "surfaceir_imported_feature",
|
|
}
|
|
|
|
designir_pipeline = _load_module("_feature_tree_designir_pipeline", SCRIPT_DIR / "designir_pipeline.py")
|
|
result = designir_pipeline.compile_designir(designir, output_step, backend=backend)
|
|
return {
|
|
"valid": True,
|
|
"output_step": str(output_step),
|
|
"output_designir": str(output_designir) if output_designir else None,
|
|
"backend": result.get("backend"),
|
|
"replay_kind": "feature_manager_native",
|
|
"facts": result.get("facts"),
|
|
}
|
|
|
|
|
|
def write_feature_tree(path: Path, tree: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(tree, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
|
|
generate = commands.add_parser("generate")
|
|
generate.add_argument("designir", type=Path)
|
|
generate.add_argument("--output", type=Path, required=True)
|
|
generate.add_argument("--designir-path")
|
|
generate.add_argument("--step")
|
|
generate.add_argument("--parameters")
|
|
generate.add_argument("--backend")
|
|
generate.add_argument("--compiled", action="store_true")
|
|
generate.add_argument("--validated", action="store_true")
|
|
|
|
validate = commands.add_parser("validate")
|
|
validate.add_argument("tree", type=Path)
|
|
|
|
compile_cmd = commands.add_parser("compile")
|
|
compile_cmd.add_argument("tree", type=Path)
|
|
compile_cmd.add_argument("--output-step", type=Path, required=True)
|
|
compile_cmd.add_argument("--output-designir", type=Path)
|
|
compile_cmd.add_argument("--backend")
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
try:
|
|
if args.command == "generate":
|
|
designir_path = args.designir.expanduser().resolve()
|
|
output_path = args.output.expanduser().resolve()
|
|
tree = generate_feature_tree(
|
|
read_designir(designir_path),
|
|
designir_path=args.designir_path or args.designir.name,
|
|
step_path=args.step,
|
|
parameters_path=args.parameters,
|
|
backend=args.backend,
|
|
compiled=args.compiled,
|
|
validated=args.validated,
|
|
)
|
|
write_feature_tree(output_path, tree)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"valid": True,
|
|
"output": str(output_path),
|
|
"node_count": len(tree["feature_manager"]["nodes"]),
|
|
"tree_kind": tree["tree_kind"],
|
|
"history_kind": tree["source"]["history_kind"],
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
)
|
|
elif args.command == "validate":
|
|
tree = json.loads(args.tree.expanduser().read_text(encoding="utf-8"))
|
|
result = validate_feature_tree(tree)
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
return 0 if result["valid"] else 1
|
|
elif args.command == "compile":
|
|
tree = json.loads(args.tree.expanduser().read_text(encoding="utf-8"))
|
|
result = compile_feature_tree(
|
|
tree,
|
|
args.output_step,
|
|
output_designir=args.output_designir,
|
|
backend=args.backend,
|
|
)
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
except Exception as exc:
|
|
print(json.dumps({"valid": False, "error": str(exc)}, ensure_ascii=False))
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|