Compare commits

...

3 Commits

5 changed files with 197 additions and 20 deletions
+6 -1
View File
@@ -46,6 +46,8 @@
"additionalProperties": false "additionalProperties": false
}, },
"hostFace": { "hostFace": {
"oneOf": [
{
"type": "object", "type": "object",
"properties": { "properties": {
"frame": { "frame": {
@@ -63,6 +65,9 @@
"required": ["frame"], "required": ["frame"],
"additionalProperties": false "additionalProperties": false
}, },
{"$ref": "#/$defs/selectorRef"}
]
},
"holePosition": { "holePosition": {
"type": "object", "type": "object",
"properties": {"mm": {"$ref": "#/$defs/point3"}}, "properties": {"mm": {"$ref": "#/$defs/point3"}},
@@ -234,7 +239,7 @@
"depth_mm": {"type": "number", "minimum": 0}, "depth_mm": {"type": "number", "minimum": 0},
"end_condition": {"$ref": "#/$defs/endCondition"}, "end_condition": {"$ref": "#/$defs/endCondition"},
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}}, "positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
"host_face": {"$ref": "#/$defs/selectorRef"}, "host_face": {"$ref": "#/$defs/hostFace"},
"thread": {"type": "object"}, "thread": {"type": "object"},
"countersink": {"type": "object"}, "countersink": {"type": "object"},
"counterbore": {"type": "object"} "counterbore": {"type": "object"}
+12 -1
View File
@@ -702,9 +702,20 @@ def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) ->
host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
) )
if positions_are_local: if positions_are_local:
for key in ("origin_mm", "x_dir", "y_dir", "normal"): for key in ("origin_mm", "x_dir", "normal"):
if host_frame.get(key): if host_frame.get(key):
host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm") host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
# #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的
# canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame
# 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。
x_reflected = host_frame.get("x_dir")
n_reflected = host_frame.get("normal")
if x_reflected is not None and n_reflected is not None:
host_frame["y_dir"] = [
n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1],
n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2],
n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0],
]
# See _mirrored_sketch: the canonical reflected plane reverses local # See _mirrored_sketch: the canonical reflected plane reverses local
# y, so local hole coordinates must do the same. # y, so local hole coordinates must do the same.
for position in params.get("positions") or []: for position in params.get("positions") or []:
+24 -1
View File
@@ -7,6 +7,7 @@ any geometry adapter without importing OCC objects.
from __future__ import annotations from __future__ import annotations
import warnings
from dataclasses import dataclass, field from dataclasses import dataclass, field
from math import sqrt from math import sqrt
from typing import Any, Iterable from typing import Any, Iterable
@@ -14,6 +15,10 @@ from typing import Any, Iterable
Vector3 = tuple[float, float, float] Vector3 = tuple[float, float, float]
# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的,
# 予以保留;否则视为偏斜数据,正交化并显式警告。
_Y_DIR_ORTHOGONALITY_TOL = 1e-6
def _vector3(value: Any, *, field_name: str) -> Vector3: def _vector3(value: Any, *, field_name: str) -> Vector3:
if not isinstance(value, (list, tuple)) or len(value) != 3: if not isinstance(value, (list, tuple)) or len(value) != 3:
@@ -176,7 +181,25 @@ class PlaneSpec:
x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir")
projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3))
x_dir = _unit(projected_x, field_name="plane.x_dir") x_dir = _unit(projected_x, field_name="plane.x_dir")
y_dir = _unit(_cross(normal, x_dir), field_name="plane.y_dir") generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir")
y_raw = value.get("y_dir")
if y_raw is None:
# y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。
y_dir = generated
else:
y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir")
if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL:
# 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向,
# 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。
y_dir = y_vec
else:
# 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。
warnings.warn(
f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}",
UserWarning,
stacklevel=2,
)
y_dir = generated
return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal)
def as_dict(self) -> dict[str, list[float]]: def as_dict(self) -> dict[str, list[float]]:
+24 -5
View File
@@ -44,14 +44,33 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
origin = workplane.get("origin_mm") or [0, 0, 0] origin = workplane.get("origin_mm") or [0, 0, 0]
x_dir = workplane.get("x_dir") or [1, 0, 0] x_dir = workplane.get("x_dir") or [1, 0, 0]
normal = workplane.get("normal") or [0, 0, 1] normal = workplane.get("normal") or [0, 0, 1]
y_dir = [ y_raw = workplane.get("y_dir")
normal[1] * x_dir[2] - normal[2] * x_dir[1], y_dir = _default_y_dir(x_dir, normal)
normal[2] * x_dir[0] - normal[0] * x_dir[2], if y_raw:
normal[0] * x_dir[1] - normal[1] * x_dir[0], magnitude = math.sqrt(sum(component * component for component in y_raw))
] if magnitude > 1e-12:
y_unit = [component / magnitude for component in y_raw]
# 与 PlaneSpec.from_mapping 同策略:只有与 x_dir / normal 正交的
# y_dir 才尊重(SolidWorks 导出的 y_dir==x_dir 占位数据与 X 平行,
# 直接使用会让轮廓塌缩成一条线,必须回退到 normal×x_dir)。
if abs(_dot(y_unit, x_dir)) <= 1e-6 and abs(_dot(y_unit, normal)) <= 1e-6:
y_dir = y_unit
return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]] return [origin[0] + u * x_dir[0] + v * y_dir[0], origin[1] + u * x_dir[1] + v * y_dir[1], origin[2] + u * x_dir[2] + v * y_dir[2]]
def _dot(left: Iterable[float], right: Iterable[float]) -> float:
return sum(a * b for a, b in zip(left, right))
def _default_y_dir(x_dir: Iterable[float], normal: Iterable[float]) -> list[float]:
x, n = list(x_dir), list(normal)
return [
n[1] * x[2] - n[2] * x[1],
n[2] * x[0] - n[0] * x[2],
n[0] * x[1] - n[1] * x[0],
]
def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]: def _transform_contours(contours: list[_Ctx], workplane: _Ctx) -> list[_Ctx]:
transformed: list[_Ctx] = [] transformed: list[_Ctx] = []
normal = workplane.get("normal") or [0, 0, 1] normal = workplane.get("normal") or [0, 0, 1]
@@ -0,0 +1,119 @@
"""#1 y_dir 静默丢弃:PlaneSpec.from_mapping 与 sketch_solver 必须尊重输入的 y_dir。
中文说明
--------
这个文件在测试什么(issue #1「y_dir 静默丢弃」的回归测试):
1. 背景:SolidWorks 导出草图常带冗余或非正交的 y_dir。
runtime 的 PlaneSpec.from_mapping(runtime_types.py:172-180)与
sketch_solver._to_3d(sketch_solver.py:43-52)都**无视输入的
y_dir 字段**,永远用 normal × x_dir 叉积补全。后果:
a) 文档作者显式给出的**正交** y_dir 被悄悄替换成叉积结果
(当 x_dir 与 normal 不正交时,重建的 y_dir 与真实几何不符);
b) **偏斜** y_dir 被静默丢弃,没有任何提示,用户不知道
坐标系被正交化过了。
2. 本测试把 y_dir 处理契约固定下来:
- 输入 y_dir 存在且与 x_dir / normal 正交 → **保留**输入值;
- 输入 y_dir 存在但偏斜(非正交)→ 正交化 + **显式 UserWarning**;
- 输入 y_dir 缺失 → 用 normal × x_dir 补全(回归护栏);
- sketch_solver 的轮廓点变换(_transform_contours → _to_3d)
同样尊重正交的输入 y_dir。
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import
cdsl_engine 包内模块(与前面几个回归测试风格一致)。
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend"))
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.runtime_types import PlaneSpec # noqa: E402
from cdsl_engine.sketch_solver import _transform_contours # noqa: E402
def _assert_vector_close(testcase: unittest.TestCase, actual: tuple | list, expected: tuple | list, *, tol: float = 1e-6) -> None:
testcase.assertEqual(len(actual), len(expected))
for left, right in zip(actual, expected):
testcase.assertAlmostEqual(float(left), float(right), delta=tol)
class PlaneYDirPreservationTests(unittest.TestCase):
"""y_dir 处理契约:正交保留 / 偏斜警告 / 缺失补全。"""
def test_orthogonal_input_y_dir_is_preserved(self) -> None:
"""主契约:输入的正交 y_dir 必须被保留,而不是被叉积结果覆盖。
构造一个 x_dir 与 normal 不垂直的 frame(这是 SolidWorks 导出里
最常见的情形):x_dir=(1,0,0)、normal=(0,1,0)。此时
normal × x_dir = (0,0,-1),但文档显式给出 y_dir=(0,0,1)。
修复前:PlaneSpec.y_dir == (0,0,-1)(静默丢弃,红灯)。
修复后:PlaneSpec.y_dir == (0,0,1)(保留输入,绿灯)。
"""
plane = PlaneSpec.from_mapping({
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 0.0, 1.0],
"normal": [0.0, 1.0, 0.0],
})
_assert_vector_close(self, plane.y_dir, (0.0, 0.0, 1.0))
def test_skewed_input_y_dir_orthogonalizes_and_warns(self) -> None:
"""主契约:偏斜 y_dir 必须被正交化,并且**显式**发出 UserWarning。
输入 y_dir=(0, 1, 1) 与 normal=(0,0,1) 的夹角不是 90°,无法直接
作为右手系 y 轴。修复前:静默替换为 normal × x_dir(无警告,红灯)。
修复后:正交化为 normal × x_dir,同时 raise UserWarning(绿灯)。
"""
with self.assertWarns(UserWarning):
plane = PlaneSpec.from_mapping({
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 1.0, 1.0],
"normal": [0.0, 0.0, 1.0],
})
# normal × x_dir = (0,0,1) × (1,0,0) = (0,1,0),且为右手系补全
_assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0))
def test_missing_y_dir_gets_orthonormal_default(self) -> None:
"""回归护栏:y_dir 字段缺失时,仍用 normal × x_dir 补全。
修复前后都应通过,这条测试防止我们把默认补全路径改坏。
"""
plane = PlaneSpec.from_mapping({
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"normal": [0.0, 0.0, 1.0],
})
_assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0))
def test_contour_transform_respects_input_y_dir(self) -> None:
"""端到端:sketch_solver 的轮廓点变换必须尊重正交的输入 y_dir。
workplane 与 test_orthogonal_input_y_dir_is_preserved 相同
(x=(1,0,0), n=(0,1,0), 输入 y=(0,0,1))。一条从 (0,0) 到 (0,1)
的轮廓直线,修复前 _to_3d 用默认 y_dir=(0,0,-1) 变换,终点落到
(0,0,-1);修复后用输入 y_dir=(0,0,1),终点落到 (0,0,1)。
"""
workplane = {
"origin_mm": [0.0, 0.0, 0.0],
"x_dir": [1.0, 0.0, 0.0],
"y_dir": [0.0, 0.0, 1.0],
"normal": [0.0, 1.0, 0.0],
}
contours = _transform_contours([
{"type": "line", "start_mm": [0.0, 0.0], "end_mm": [0.0, 1.0]},
], workplane)
_assert_vector_close(self, contours[0]["start_mm"], (0.0, 0.0, 0.0))
_assert_vector_close(self, contours[0]["end_mm"], (0.0, 0.0, 1.0))
if __name__ == "__main__":
unittest.main()