Merge pull request 'Ganjihong' (#6) from ganjihong into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-08-31 17:01:24 +08:00
5 changed files with 180 additions and 4 deletions
@@ -102,6 +102,12 @@ def pattern_transform_blocker(source: FeaturePlanNode) -> str | None:
# 孔宿主面:主体上的固定面,不随实例平移;实例位置由 positions
# 平移决定(_translated_node),selector 原样保留即可正确 resolve。
continue
if selector.get("kind") == "plane" and source.atomic_id == "pattern_mirror" and selector.get("owner_feature_id"):
# #6 pattern 引用重解析:pattern_mirror 的镜像面是对
# reference_plane 的 plane 引用(带 owner_feature_id),重放时
# 由运行时从 owner 解析显式 frame 并随实例变换
# _translated_node/_mirrored_node 内联 frame),放行。
continue
return "feature selector"
host = source.params.get("host_face")
if host is not None and not _has_explicit_host_frame(source.params):
+97 -4
View File
@@ -664,7 +664,23 @@ def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any
return output
def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3) -> FeaturePlanNode:
def _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None:
"""解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。
#6 pattern 引用重解析:pattern 重放 sourcepattern_mirror)时,镜像面
是 selector,其 owner 是 reference_plane 特征;该特征执行时把显式
PlaneSpec 登记为拓扑上下文,这里取出该 frame 供随实例变换使用。
"""
owner = selector.get("owner_feature_id")
if not owner:
return None
for record in session.topology.records_for_feature(str(owner)):
if record.kind == "plane" and isinstance(record.value, PlaneSpec):
return record.value.as_dict()
return None
def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, session: ExecutionSession) -> FeaturePlanNode:
params = deepcopy(node.params)
components = offset
if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"):
@@ -683,6 +699,45 @@ def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3) -
axis = params.get("axis") or {}
if axis.get("origin_mm"):
axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)]
mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移
# 到新位置后内联为显式 frame;否则重放时 resolve 到原始面,镜像
# 副本会错误地重合在源特征附近。同时源特征也必须平移后重放:镜像
# 副本 = reflect(源@t, 面@t),只平移面不平移源会落在 2P+t-x 处
# 而非正确位置 2P-x+t。
frame = _owner_plane_frame(session, mirror_plane)
if frame is None:
raise ValueError("mirror plane reference cannot be transformed for pattern replay")
cloned_selector = deepcopy(mirror_plane)
cloned_selector["frame"] = {
"origin_mm": [frame["origin_mm"][index] + components[index] for index in range(3)],
"x_dir": list(frame["x_dir"]),
"normal": list(frame["normal"]),
}
params["mirror_plane"] = cloned_selector
transformed_ids: list[str] = []
for source_id in node.params.get("source_feature_ids") or []:
source_node = session.replay_definitions.get(str(source_id))
if source_node is None:
raise ValueError(f"mirror pattern source feature {source_id} has no replay definition")
temp_id = f"{instance_id}.src.{source_id}"
shifted = _translated_node(source_node, temp_id, offset, session)
if shifted.sketch_id:
source_sketch = session.sketches.get(str(source_node.sketch_id))
if source_sketch is not None:
temp_sketch_id = f"{temp_id}.sk"
session.sketches[temp_sketch_id] = _translated_sketch(source_sketch, offset)
shifted = FeaturePlanNode(
shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on,
shifted.params, shifted.selectors, temp_sketch_id,
shifted.declared_status, shifted.source_feature,
)
# 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。
session.nodes[temp_id] = shifted
session.replay_definitions[temp_id] = shifted
transformed_ids.append(temp_id)
params["source_feature_ids"] = transformed_ids
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
@@ -712,7 +767,7 @@ def _execute_linear_pattern(node: FeaturePlanNode, session: ExecutionSession, ex
dependency = pattern_transform_blocker(source)
if dependency:
raise ValueError(f"pattern source uses an unsupported {dependency}")
cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset)
cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session)
sketch = session.sketches.get(str(source.sketch_id))
execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None)
# 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放,
@@ -779,7 +834,7 @@ def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]
return output
def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) -> FeaturePlanNode:
def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, session: ExecutionSession) -> FeaturePlanNode:
params = deepcopy(node.params)
if isinstance(params.get("plane"), dict):
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
@@ -820,6 +875,44 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) ->
axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane)
if axis.get("direction"):
axis["direction"] = _reflect_point(axis["direction"], plane, vector=True)
mirror_plane = params.get("mirror_plane")
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
# #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用
# 随本实例的镜像面一起反射(内联为显式 frame),否则重放 resolve
# 到原始面,嵌套镜像会退化成与源镜像重合的错误几何。源特征同样
# 反射后重放:镜像副本 = reflect(源@P_B, reflect(面,P_B))。
frame = _owner_plane_frame(session, mirror_plane)
if frame is None:
raise ValueError("mirror plane reference cannot be transformed for pattern replay")
cloned_selector = deepcopy(mirror_plane)
cloned_selector["frame"] = {
"origin_mm": _reflect_point(frame["origin_mm"], plane),
"x_dir": _reflect_point(frame["x_dir"], plane, vector=True),
"normal": _reflect_point(frame["normal"], plane, vector=True),
}
params["mirror_plane"] = cloned_selector
transformed_ids: list[str] = []
for source_id in node.params.get("source_feature_ids") or []:
source_node = session.replay_definitions.get(str(source_id))
if source_node is None:
raise ValueError(f"mirror pattern source feature {source_id} has no replay definition")
temp_id = f"{instance_id}.src.{source_id}"
shifted = _mirrored_node(source_node, temp_id, plane, session)
if shifted.sketch_id:
source_sketch = session.sketches.get(str(source_node.sketch_id))
if source_sketch is not None:
temp_sketch_id = f"{temp_id}.sk"
session.sketches[temp_sketch_id] = _mirrored_sketch(source_sketch, plane)
shifted = FeaturePlanNode(
shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on,
shifted.params, shifted.selectors, temp_sketch_id,
shifted.declared_status, shifted.source_feature,
)
# 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。
session.nodes[temp_id] = shifted
session.replay_definitions[temp_id] = shifted
transformed_ids.append(temp_id)
params["source_feature_ids"] = transformed_ids
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
@@ -835,7 +928,7 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) ->
dependency = pattern_transform_blocker(source)
if dependency:
raise ValueError(f"mirror pattern source uses an unsupported {dependency}")
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value)
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session)
sketch = session.sketches.get(str(source.sketch_id))
_execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None)
session.replay_definitions[node.feature_id] = node
@@ -719,6 +719,39 @@ class TopologyRegistry:
]
if owner:
candidates = [record for record in candidates if owner in record.owners]
if kind == "plane" and selector.get("frame") is not None:
# #6 pattern 引用重解析:pattern 重放 sourcepattern_mirror)时,
# mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame
# _translated_node / _mirrored_node),这里直接构造 PlaneSpec
# 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。
try:
plane = PlaneSpec.from_mapping(selector.get("frame") or {})
except (TypeError, ValueError):
plane = None
if plane is not None:
record = TopologyRecord(
record_id=f"inline:{id(plane)}",
kind="plane",
feature_id=str(owner or "inline"),
geometry=plane.as_dict(),
value=plane,
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": 1.0, **record.public_dict()},),
)
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_frame_incomplete",
message="An inline plane frame requires origin_mm, x_dir and normal",
detail={"frame": selector.get("frame")},
),
)
geometry = normalize_selector_geometry(selector.get("geometry"))
if selector.get("snapshot_id") and not owner:
return SelectorResolution(
+38
View File
@@ -174,6 +174,44 @@ class EvidenceV2ToCdslTests(unittest.TestCase):
self.assertTrue(comparison["numeric_geometry_match"])
self.assertTrue(comparison["topology_counts_match"])
def _chamfer_only_fixture(self, dimensions: list[float], props: dict | None = None) -> dict:
return {
"schema": "solidworks.cad_evidence.v2",
"status": "complete_with_blockers",
"self_validation": {"feature_contracts": []},
"features": [{
"sequence": 1, "name": "Chamfer", "effective_type": "Chamfer", "stable_id": "chamfer",
"definition_properties": {"values": props or {}},
"definition_methods": {"values": {}}, "parents": [], "selections": [],
"dimensions": [{"system_value": value} for value in dimensions],
}],
}
def test_chamfer_exports_radian_angle_instead_of_silent_equidistant_distance(self) -> None:
# Distance-Angle 倒角:dimensions=[0.001m, 0.785398],无 EdgeChamferAngle 属性,
# 走 raw 兜底路径。角度 system value 为弧度,distance_mm 按米→毫米换算。
fixture = self._chamfer_only_fixture([0.001, 0.785398])
cdsl, _ = convert_evidence(fixture, source_name="chamfer-angle.solidworks_evidence_v2.json")
params = next(item for item in cdsl["features"] if item["atomic_id"] == "chamfer")["params"]
self.assertAlmostEqual(params["distance_mm"], 1.0, places=9)
self.assertNotIn("distance_2_mm", params)
self.assertAlmostEqual(params["angle_rad"], 0.785398, places=5)
def test_chamfer_prefers_edge_chamfer_angle_property(self) -> None:
fixture = self._chamfer_only_fixture([0.001, 0.785398], props={"EdgeChamferAngle": 1.047197551})
cdsl, _ = convert_evidence(fixture, source_name="chamfer-prop.solidworks_evidence_v2.json")
params = next(item for item in cdsl["features"] if item["atomic_id"] == "chamfer")["params"]
self.assertAlmostEqual(params["distance_mm"], 1.0, places=9)
self.assertAlmostEqual(params["angle_rad"], 1.047197551, places=6)
def test_chamfer_keeps_equidistant_form_when_angle_is_missing(self) -> None:
fixture = self._chamfer_only_fixture([0.001])
cdsl, _ = convert_evidence(fixture, source_name="chamfer-eq.solidworks_evidence_v2.json")
params = next(item for item in cdsl["features"] if item["atomic_id"] == "chamfer")["params"]
self.assertAlmostEqual(params["distance_mm"], 1.0, places=9)
self.assertNotIn("angle_rad", params)
self.assertNotIn("distance_2_mm", params)
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -611,8 +611,14 @@ def _deferred_params(family: str, feature: dict[str, Any], props: dict[str, Any]
params: dict[str, Any] = {"distance_mm": distance}
angle = _number(props.get("EdgeChamferAngle"))
if angle <= 0 and len(raw_dimensions) > 1:
# 兜底:EdgeChamferAngle 缺失时,第二个 raw 值按角度(弧度)解释。
# 注意:SolidWorks 角度 system value 为弧度;Distance-Distance 倒角的
# 第二距离(米)也会落入该兜底,但真实数据中倒角均为 Distance-Angle
# (第二值为 π/4 等弧度值),runtime 通过 distance_2_mm/angle_rad
# 契约消费第二参数,不会把角度静默当等距。
angle = raw_dimensions[1]
if angle > 0:
# Distance-Angle 倒角:角度单位为弧度(build123d 侧由 runtime 换算)。
params["angle_rad"] = angle
return params, []
if family == "LPattern":