Compare commits
3 Commits
e7ade4aa00
...
0acb29e96d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0acb29e96d | |||
| c5f789c385 | |||
| 2ef31db17e |
@@ -46,22 +46,27 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"hostFace": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"frame": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"origin_mm": {"$ref": "#/$defs/point3"},
|
||||
"x_dir": {"$ref": "#/$defs/point3"},
|
||||
"y_dir": {"$ref": "#/$defs/point3"},
|
||||
"normal": {"$ref": "#/$defs/point3"}
|
||||
"frame": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"origin_mm": {"$ref": "#/$defs/point3"},
|
||||
"x_dir": {"$ref": "#/$defs/point3"},
|
||||
"y_dir": {"$ref": "#/$defs/point3"},
|
||||
"normal": {"$ref": "#/$defs/point3"}
|
||||
},
|
||||
"required": ["origin_mm", "x_dir", "y_dir", "normal"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["origin_mm", "x_dir", "y_dir", "normal"],
|
||||
"required": ["frame"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["frame"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{"$ref": "#/$defs/selectorRef"}
|
||||
]
|
||||
},
|
||||
"holePosition": {
|
||||
"type": "object",
|
||||
@@ -234,7 +239,7 @@
|
||||
"depth_mm": {"type": "number", "minimum": 0},
|
||||
"end_condition": {"$ref": "#/$defs/endCondition"},
|
||||
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
|
||||
"host_face": {"$ref": "#/$defs/selectorRef"},
|
||||
"host_face": {"$ref": "#/$defs/hostFace"},
|
||||
"thread": {"type": "object"},
|
||||
"countersink": {"type": "object"},
|
||||
"counterbore": {"type": "object"}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
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):
|
||||
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
|
||||
# y, so local hole coordinates must do the same.
|
||||
for position in params.get("positions") or []:
|
||||
|
||||
@@ -7,6 +7,7 @@ any geometry adapter without importing OCC objects.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from math import sqrt
|
||||
from typing import Any, Iterable
|
||||
@@ -14,6 +15,10 @@ from typing import Any, Iterable
|
||||
|
||||
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:
|
||||
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")
|
||||
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")
|
||||
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)
|
||||
|
||||
def as_dict(self) -> dict[str, list[float]]:
|
||||
|
||||
@@ -44,14 +44,33 @@ def _to_3d(workplane: _Ctx, u: float, v: float) -> list[float]:
|
||||
origin = workplane.get("origin_mm") or [0, 0, 0]
|
||||
x_dir = workplane.get("x_dir") or [1, 0, 0]
|
||||
normal = workplane.get("normal") or [0, 0, 1]
|
||||
y_dir = [
|
||||
normal[1] * x_dir[2] - normal[2] * x_dir[1],
|
||||
normal[2] * x_dir[0] - normal[0] * x_dir[2],
|
||||
normal[0] * x_dir[1] - normal[1] * x_dir[0],
|
||||
]
|
||||
y_raw = workplane.get("y_dir")
|
||||
y_dir = _default_y_dir(x_dir, normal)
|
||||
if y_raw:
|
||||
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]]
|
||||
|
||||
|
||||
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]:
|
||||
transformed: list[_Ctx] = []
|
||||
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()
|
||||
Reference in New Issue
Block a user