fix(json_to_cdsl): 修复参考平面上旋转轮廓的工作平面重建

This commit is contained in:
2026-08-26 14:39:04 +08:00
parent b66b02c244
commit 8863908430
+126 -1
View File
@@ -184,9 +184,22 @@ def _workplane(sketch: dict[str, Any]) -> dict[str, Any]:
A conservative XY fallback keeps the record valid when the exporter did
not provide a usable matrix.
"""
workplane = _workplane_from_matrix(sketch)
if workplane is not None:
return workplane
return {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
def _workplane_from_matrix(sketch: dict[str, Any]) -> dict[str, Any] | None:
"""Parse the exporter's model-to-sketch matrix into a workplane.
Returns None when no usable rigid rotation can be extracted (missing
matrix or a non-orthonormal frame). The caller then decides between a
rebuilt workplane and the conservative XY fallback.
"""
matrix = sketch.get("model_to_sketch_transform")
if not isinstance(matrix, list) or len(matrix) != 16 or not all(isinstance(item, (int, float)) for item in matrix):
return {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}
return None
# The exporter serializes MathTransform in row-major form.
rotation = [matrix[0:3], matrix[4:7], matrix[8:11]]
translation = [matrix[3], matrix[7], matrix[11]]
@@ -195,9 +208,69 @@ def _workplane(sketch: dict[str, Any]) -> dict[str, Any]:
x_dir = _normalize([inverse_rotation[row][0] for row in range(3)])
y_dir = _normalize([inverse_rotation[row][1] for row in range(3)])
normal = _normalize(_cross(x_dir, y_dir))
# A rigid model-to-sketch rotation keeps the sketch axes orthogonal. The
# exporter sometimes emits a non-orthogonal matrix for profiles placed on
# a copied reference plane; only trust a frame that is truly orthonormal.
if abs(x_dir[0] * y_dir[0] + x_dir[1] * y_dir[1] + x_dir[2] * y_dir[2]) > 1e-9:
return None
return {"origin_mm": [_mm(value) for value in origin_m], "x_dir": x_dir, "y_dir": y_dir, "normal": normal}
def _workplane_is_orthonormal(workplane: dict[str, Any]) -> bool:
"""True when a workplane's sketch axes describe an orthonormal frame."""
x_dir = workplane.get("x_dir")
y_dir = workplane.get("y_dir")
if not x_dir or not y_dir:
return False
return abs(x_dir[0] * y_dir[0] + x_dir[1] * y_dir[1] + x_dir[2] * y_dir[2]) <= 1e-9
def _workplane_rebuilt(parent_plane: dict[str, Any] | None, axis_direction: list[float] | None) -> dict[str, Any] | None:
"""Rebuild a workplane from the parent reference plane and the revolve axis.
When the exporter's matrix is unusable, the profile still lies on its
parent reference plane and the revolve axis runs along the sketch V axis.
The captured axis direction is world space, so V maps directly onto it and
U is completed by the cross product to keep a right-handed frame.
"""
if not parent_plane or not axis_direction:
return None
normal = _normalize(list(parent_plane.get("normal") or []))
y_dir = _normalize(list(axis_direction))
if abs(normal[0] * y_dir[0] + normal[1] * y_dir[1] + normal[2] * y_dir[2]) > 0.9:
# The axis would lie parallel to the plane normal and the revolve
# would degenerate to zero volume; do not trust this rebuild.
return None
x_dir = _normalize(_cross(y_dir, normal))
origin_mm = list(parent_plane.get("origin_mm") or [0.0, 0.0, 0.0])
return {"origin_mm": origin_mm, "x_dir": x_dir, "y_dir": y_dir, "normal": normal}
def _axis_in_world(axis: dict[str, Any], workplane: dict[str, Any]) -> dict[str, Any]:
"""Transform a revolve axis captured in sketch-local coordinates to world space."""
origin = axis.get("origin_mm")
direction = axis.get("direction")
if not origin or not direction or not workplane.get("x_dir"):
return axis
origin_mm = workplane.get("origin_mm") or [0.0, 0.0, 0.0]
x_dir = workplane["x_dir"]
y_dir = workplane.get("y_dir") or [0.0, 1.0, 0.0]
normal = workplane.get("normal") or [0.0, 0.0, 1.0]
world_origin = [
origin_mm[index] + origin[0] * x_dir[index] + origin[1] * y_dir[index] + origin[2] * normal[index]
for index in range(3)
]
world_direction = _normalize([
direction[0] * x_dir[index] + direction[1] * y_dir[index] + direction[2] * normal[index]
for index in range(3)
])
return {
**axis,
"origin_mm": [round(value, 9) for value in world_origin],
"direction": world_direction if math.sqrt(sum(c * c for c in world_direction)) > 1e-12 else list(direction),
}
def _workplane_from_plane_reference(reference: Any) -> dict[str, Any] | None:
"""Build a workplane from an exporter-captured planar face signature."""
if not isinstance(reference, dict):
@@ -892,6 +965,25 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
sketch_id_by_name = {str(feature.get("name")): sketch_id_by_source[source_id] for feature in sketch_features if (source_id := _identity_key(feature)) and feature.get("name") is not None}
sketch_workplanes_by_parent_source: dict[str, tuple[str, dict[str, Any]]] = {}
sketch_workplanes_by_parent_name: dict[str, tuple[str, dict[str, Any]]] = {}
# sketch source id -> the parent reference-plane source id it is drawn on
sketch_parent_plane_by_source: dict[str, str] = {}
# sketch source id -> the world-space direction of its revolve axis
sketch_axis_direction_by_source: dict[str, list[float]] = {}
# sketch source ids whose exporter matrix did not form an orthonormal frame
sketch_matrix_unreliable: set[str] = set()
sketch_record_by_id: dict[str, dict[str, Any]] = {}
# Prescan the revolve features so each profile's workplane can be rebuilt
# from the axis direction captured on its own revolve feature.
for feature in features:
if _feature_family(feature) not in {"Revolution", "RevCut"}:
continue
props, _ = _record_values(feature)
axis, _ = _axis_from_reference(props.get("Axis"))
if not axis or not axis.get("direction"):
continue
for parent in _record_parents(feature):
if str(parent.get("effective_type") or "") in SKETCH_TYPES and (sketch_source_id := _identity_key(parent)):
sketch_axis_direction_by_source.setdefault(sketch_source_id, list(axis["direction"]))
sketches = []
for feature in sketch_features:
source_id = _identity_key(feature)
@@ -901,10 +993,15 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
workplane = _workplane(sketch)
record = {"id": sketch_id_by_source[source_id], "name": str(feature.get("name") or sketch_id_by_source[source_id]), "role": "profile", "workplane": workplane, "profile": _analytic_profile(sketch)}
sketches.append(record)
sketch_record_by_id[record["id"]] = record
if not _workplane_is_orthonormal(workplane):
sketch_matrix_unreliable.add(source_id)
for parent in _record_parents(feature):
parent_source_id = _identity_key(parent)
if parent_source_id:
sketch_workplanes_by_parent_source.setdefault(parent_source_id, (record["id"], workplane))
if _feature_family(parent) == "RefPlane":
sketch_parent_plane_by_source[source_id] = parent_source_id
if parent.get("name") is not None:
sketch_workplanes_by_parent_name.setdefault(str(parent["name"]), (record["id"], workplane))
@@ -944,6 +1041,11 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
cdsl_features: list[dict[str, Any]] = []
previous_id: str | None = None
diagnostics: list[dict[str, Any]] = []
# Sketch source ids whose workplane was successfully rebuilt once their
# parent reference plane was resolved. Their revolve axis is expressed in
# sketch-local coordinates and must be transformed to world space too.
sketch_fix_applied: set[str] = set()
sketch_source_by_id = {sketch_id: source_id for source_id, sketch_id in sketch_id_by_source.items()}
for feature in model_features:
source_id = _identity_key(feature)
@@ -985,6 +1087,14 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
selectors.append(axis_selector)
if props.get("Axis") and any("no captured semantic selections" in item for item in unresolved):
unresolved = [item for item in unresolved if "no captured semantic selections" not in item]
# When the profile workplane was rebuilt, the captured revolve axis
# is still in sketch-local coordinates; map it to world space so
# the cut lands on the resolved parent reference plane.
if sketch_id and (axis_source_id := sketch_source_by_id.get(sketch_id)) in sketch_fix_applied:
sketch_workplane = sketch_record_by_id.get(sketch_id)
axis = params.get("axis")
if isinstance(axis, dict) and sketch_workplane is not None:
params["axis"] = _axis_in_world(axis, sketch_workplane["workplane"])
elif family == "HoleWzd":
atomic_id = "hole_wizard"
params, positions = _hole_params(props, methods)
@@ -1065,6 +1175,21 @@ def convert_evidence(evidence: dict[str, Any], *, source_name: str, step_path: P
if "unresolved" not in params["plane"]:
more_unresolved = [item for item in more_unresolved if item != "reference plane orientation was not captured"]
unresolved = [item for item in unresolved if item != "reference plane orientation was not captured"]
plane = params.get("plane")
if isinstance(plane, dict) and "origin_mm" in plane and "unresolved" not in plane:
# Profiles whose exporter matrix was unusable are rebuilt from
# this resolved reference plane (normal + origin) and the axis
# direction of the profile's own revolve feature.
for sketch_source_id, ref_source_id in sketch_parent_plane_by_source.items():
if ref_source_id != source_id or sketch_source_id not in sketch_matrix_unreliable:
continue
rebuilt = _workplane_rebuilt(plane, sketch_axis_direction_by_source.get(sketch_source_id))
if rebuilt is None:
continue
sketch_record = sketch_record_by_id.get(sketch_id_by_source.get(sketch_source_id) or "")
if sketch_record is not None:
sketch_record["workplane"] = rebuilt
sketch_fix_applied.add(sketch_source_id)
for reference in params.get("references", []):
if reference not in selectors:
selectors.append(reference)