first commit
This commit is contained in:
@@ -0,0 +1,900 @@
|
||||
"""Comprehensive unit tests for the SimpleCAD API, covering basic operations and advanced features."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import io
|
||||
import unittest
|
||||
import numpy as np
|
||||
import tempfile
|
||||
import shutil
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
# 添加项目路径到Python路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
|
||||
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
class TestBasicShapes(unittest.TestCase):
|
||||
"""Tests for basic shape creation."""
|
||||
|
||||
def test_create_point(self):
|
||||
"""Test create point."""
|
||||
point = scad.make_point_rvertex(1, 2, 3)
|
||||
# 暂时跳过坐标检查,因为点类型可能不同
|
||||
self.assertIsInstance(point, scad.Vertex)
|
||||
|
||||
def test_create_line(self):
|
||||
"""Test create line."""
|
||||
line = scad.make_line_redge((0, 0, 0), (1, 0, 0))
|
||||
self.assertIsInstance(line, scad.Edge)
|
||||
|
||||
def test_create_circle_edge(self):
|
||||
"""Test create circle edge."""
|
||||
circle_edge = scad.make_circle_redge((0, 0, 0), 1.0)
|
||||
self.assertIsInstance(circle_edge, scad.Edge)
|
||||
|
||||
def test_create_circle_wire(self):
|
||||
"""Test create circle wire."""
|
||||
circle_wire = scad.make_circle_rwire((0, 0, 0), 1.0)
|
||||
self.assertIsInstance(circle_wire, scad.Wire)
|
||||
|
||||
def test_create_circle_face(self):
|
||||
"""Test create circle face."""
|
||||
circle_face = scad.make_circle_rface((0, 0, 0), 1.0)
|
||||
area = circle_face.get_area()
|
||||
self.assertAlmostEqual(area, np.pi, places=6)
|
||||
|
||||
def test_create_rectangle_wire(self):
|
||||
"""Test create rectangle wire."""
|
||||
rect_wire = scad.make_rectangle_rwire(2.0, 1.0)
|
||||
self.assertIsInstance(rect_wire, scad.Wire)
|
||||
|
||||
def test_create_rectangle_face(self):
|
||||
"""Test create rectangle face."""
|
||||
rect_face = scad.make_rectangle_rface(2.0, 1.0)
|
||||
area = rect_face.get_area()
|
||||
self.assertAlmostEqual(area, 2.0, places=6)
|
||||
|
||||
def test_create_box(self):
|
||||
"""Test create box."""
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
volume = box.get_volume()
|
||||
self.assertAlmostEqual(volume, 1.0, places=6)
|
||||
|
||||
def test_create_cylinder(self):
|
||||
"""Test create cylinder."""
|
||||
cylinder = scad.make_cylinder_rsolid(1.0, 2.0)
|
||||
volume = cylinder.get_volume()
|
||||
expected_volume = np.pi * 1.0**2 * 2.0
|
||||
self.assertAlmostEqual(volume, expected_volume, places=6)
|
||||
|
||||
def test_create_sphere(self):
|
||||
"""Test create sphere."""
|
||||
sphere = scad.make_sphere_rsolid(1.0)
|
||||
volume = sphere.get_volume()
|
||||
expected_volume = (4 / 3) * np.pi * 1.0**3
|
||||
self.assertAlmostEqual(volume, expected_volume, places=5)
|
||||
|
||||
def test_create_cone(self):
|
||||
"""Test create cone."""
|
||||
# 测试标准圆锥体(尖锥)
|
||||
cone = scad.make_cone_rsolid(2.0, 3.0)
|
||||
volume = cone.get_volume()
|
||||
expected_volume = (1 / 3) * np.pi * 2.0**2 * 3.0
|
||||
self.assertAlmostEqual(volume, expected_volume, places=5)
|
||||
self.assertIn("geom.primitive.cone", scad.list_tags(cone))
|
||||
|
||||
def test_create_truncated_cone(self):
|
||||
"""Test create truncated cone."""
|
||||
# 测试截锥体(顶面半径不为0)
|
||||
truncated_cone = scad.make_cone_rsolid(3.0, 4.0, 1.0)
|
||||
volume = truncated_cone.get_volume()
|
||||
# 截锥体积公式:V = (1/3)πh(R² + Rr + r²)
|
||||
# 其中 R = 3.0, r = 1.0, h = 4.0
|
||||
expected_volume = (1 / 3) * np.pi * 4.0 * (3.0**2 + 3.0 * 1.0 + 1.0**2)
|
||||
self.assertAlmostEqual(volume, expected_volume, places=5)
|
||||
self.assertIn("geom.primitive.cone", scad.list_tags(truncated_cone))
|
||||
|
||||
def test_create_cone_with_offset(self):
|
||||
"""Test create cone with offset."""
|
||||
# 测试底面中心偏移的圆锥体
|
||||
offset_cone = scad.make_cone_rsolid(1.5, 2.0, bottom_face_center=(2, 2, 0))
|
||||
self.assertIsInstance(offset_cone, scad.Solid)
|
||||
self.assertIn("geom.primitive.cone", scad.list_tags(offset_cone))
|
||||
|
||||
def test_create_cone_with_axis(self):
|
||||
"""Test create cone with axis."""
|
||||
# 测试水平方向的圆锥体
|
||||
horizontal_cone = scad.make_cone_rsolid(1.0, 3.0, axis=(1, 0, 0))
|
||||
self.assertIsInstance(horizontal_cone, scad.Solid)
|
||||
self.assertIn("geom.primitive.cone", scad.list_tags(horizontal_cone))
|
||||
|
||||
def test_create_arc(self):
|
||||
"""Test create arc."""
|
||||
arc = scad.make_three_point_arc_redge((0, 0, 0), (1, 1, 0), (2, 0, 0))
|
||||
self.assertIsInstance(arc, scad.Edge)
|
||||
|
||||
def test_create_spline(self):
|
||||
"""Test create spline."""
|
||||
spline = scad.make_spline_redge(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.6, 1.0, 0.0),
|
||||
(1.4, 1.0, 0.0),
|
||||
(2.0, 0.0, 0.0),
|
||||
]
|
||||
)
|
||||
self.assertIsInstance(spline, scad.Edge)
|
||||
|
||||
def test_create_segment_edge(self):
|
||||
"""Test create segment edge."""
|
||||
segment = scad.make_segment_redge((0, 0, 0), (1, 0, 0))
|
||||
self.assertIsInstance(segment, scad.Edge)
|
||||
|
||||
def test_create_segment_wire(self):
|
||||
"""Test create segment wire."""
|
||||
segment_wire = scad.make_segment_rwire((0, 0, 0), (1, 0, 0))
|
||||
self.assertIsInstance(segment_wire, scad.Wire)
|
||||
|
||||
def test_create_angle_arc_edge(self):
|
||||
"""Test create angle arc edge."""
|
||||
arc = scad.make_angle_arc_redge((0, 0, 0), 1.0, 0, np.pi / 2)
|
||||
self.assertIsInstance(arc, scad.Edge)
|
||||
|
||||
def test_create_angle_arc_wire(self):
|
||||
"""Test create angle arc wire."""
|
||||
arc_wire = scad.make_angle_arc_rwire((0, 0, 0), 1.0, 0, np.pi / 2)
|
||||
self.assertIsInstance(arc_wire, scad.Wire)
|
||||
|
||||
def test_create_three_point_arc_wire(self):
|
||||
"""Test create three point arc wire."""
|
||||
arc_wire = scad.make_three_point_arc_rwire((0, 0, 0), (1, 1, 0), (2, 0, 0))
|
||||
self.assertIsInstance(arc_wire, scad.Wire)
|
||||
|
||||
def test_create_spline_wire(self):
|
||||
"""Test create spline wire."""
|
||||
spline_wire = scad.make_spline_rwire(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.6, 1.0, 0.0),
|
||||
(1.4, 1.0, 0.0),
|
||||
(2.0, 0.0, 0.0),
|
||||
]
|
||||
)
|
||||
self.assertIsInstance(spline_wire, scad.Wire)
|
||||
|
||||
def test_create_polyline_wire(self):
|
||||
"""Test create polyline wire."""
|
||||
points = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0)]
|
||||
polyline_wire = scad.make_polyline_rwire(points)
|
||||
self.assertIsInstance(polyline_wire, scad.Wire)
|
||||
|
||||
# 测试闭合多段线
|
||||
closed_polyline = scad.make_polyline_rwire(points, closed=True)
|
||||
self.assertIsInstance(closed_polyline, scad.Wire)
|
||||
self.assertTrue(closed_polyline.is_closed())
|
||||
|
||||
def test_create_helix_edge(self):
|
||||
"""Test create helix edge."""
|
||||
helix = scad.make_helix_redge(pitch=1.0, height=3.0, radius=0.5)
|
||||
self.assertIsInstance(helix, scad.Edge)
|
||||
|
||||
def test_create_helix_wire(self):
|
||||
"""Test create helix wire."""
|
||||
helix_wire = scad.make_helix_rwire(pitch=1.0, height=3.0, radius=0.5)
|
||||
self.assertIsInstance(helix_wire, scad.Wire)
|
||||
|
||||
def test_new_function_error_handling(self):
|
||||
"""Test new function error handling."""
|
||||
# 测试无效参数
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_angle_arc_redge((0, 0, 0), -1.0, 0, np.pi / 2) # 负半径
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_angle_arc_redge((0, 0, 0), 1.0, 0, 0) # 相同角度
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_helix_redge(-1.0, 3.0, 0.5) # 负螺距
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_helix_redge(1.0, -3.0, 0.5) # 负高度
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_helix_redge(1.0, 3.0, -0.5) # 负半径
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_spline_redge(control_points=[(0, 0, 0)]) # 控制点不足
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_polyline_rwire([(0, 0, 0)]) # 点数不足
|
||||
|
||||
|
||||
class TestTransformations(unittest.TestCase):
|
||||
"""Tests for transformation operations."""
|
||||
|
||||
def setUp(self):
|
||||
self.box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
|
||||
def test_translate(self):
|
||||
"""Test translate."""
|
||||
translated = scad.translate_shape(self.box, (1, 0, 0))
|
||||
self.assertIsInstance(translated, scad.Solid)
|
||||
# 体积应保持不变
|
||||
if isinstance(translated, scad.Solid):
|
||||
self.assertAlmostEqual(
|
||||
translated.get_volume(), self.box.get_volume(), places=6
|
||||
)
|
||||
|
||||
def test_rotate(self):
|
||||
"""Test rotate."""
|
||||
rotated = scad.rotate_shape(self.box, np.pi / 4, (0, 0, 1))
|
||||
self.assertIsInstance(rotated, scad.Solid)
|
||||
# 体积应保持不变
|
||||
if isinstance(rotated, scad.Solid):
|
||||
self.assertAlmostEqual(
|
||||
rotated.get_volume(), self.box.get_volume(), places=6
|
||||
)
|
||||
|
||||
|
||||
class Test3DOperations(unittest.TestCase):
|
||||
"""Tests for 3D operations."""
|
||||
|
||||
def test_extrude(self):
|
||||
"""Test extrude."""
|
||||
rect = scad.make_rectangle_rface(2.0, 1.0)
|
||||
extruded = scad.extrude_rsolid(rect, (0, 0, 1), 2.0)
|
||||
self.assertIsInstance(extruded, scad.Solid)
|
||||
# 体积应该是面积乘以高度
|
||||
expected_volume = rect.get_area() * 2.0
|
||||
self.assertAlmostEqual(extruded.get_volume(), expected_volume, places=6)
|
||||
|
||||
def test_revolve(self):
|
||||
"""Test revolve."""
|
||||
rect = scad.make_rectangle_rface(1.0, 2.0, center=(2, 0, 0))
|
||||
revolved = scad.revolve_rsolid(rect, (0, 1, 0), 180, (0, 0, 0))
|
||||
self.assertIsInstance(revolved, scad.Solid)
|
||||
self.assertGreater(revolved.get_volume(), 0)
|
||||
|
||||
|
||||
class TestBooleanOperations(unittest.TestCase):
|
||||
"""Tests for boolean operations."""
|
||||
|
||||
def setUp(self):
|
||||
self.box1 = scad.make_box_rsolid(2.0, 2.0, 2.0)
|
||||
self.box2 = scad.make_box_rsolid(
|
||||
1.0, 1.0, 3.0, bottom_face_center=(0.5, 0.5, 0)
|
||||
)
|
||||
|
||||
def test_union(self):
|
||||
"""Test union."""
|
||||
result = scad.union_rsolid([self.box1, self.box2])
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
# 并集体积应该大于任一单独体积
|
||||
self.assertGreater(result.get_volume(), self.box1.get_volume())
|
||||
self.assertGreater(result.get_volume(), self.box2.get_volume())
|
||||
|
||||
def test_union_disconnected_solids(self):
|
||||
"""Test union disconnected solids."""
|
||||
|
||||
box_far_1 = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(0, 0, 0))
|
||||
box_far_2 = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(5, 0, 0))
|
||||
box_far_3 = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(0, 5, 0))
|
||||
|
||||
solids = [box_far_1, box_far_2, box_far_3]
|
||||
with self.assertRaises(scad.SimpleCADError) as ctx:
|
||||
scad.union_rsolid(solids)
|
||||
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("union_rsolid", message)
|
||||
self.assertIn("单个Solid结果", message)
|
||||
|
||||
def test_union_touching_boxes_cleans_splitter_faces(self):
|
||||
"""Test union of face-touching boxes follows CadQuery-style clean behavior."""
|
||||
|
||||
box_left = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(0, 0, 0))
|
||||
box_right = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(1.0, 0, 0))
|
||||
|
||||
stdout_buffer = io.StringIO()
|
||||
with redirect_stdout(stdout_buffer):
|
||||
result = scad.union_rsolid(box_left, box_right)
|
||||
|
||||
self.assertAlmostEqual(result.get_volume(), 2.0, places=6)
|
||||
self.assertEqual(len(result.get_faces()), 6)
|
||||
self.assertEqual(stdout_buffer.getvalue(), "")
|
||||
|
||||
def test_union_supports_fuzzy_tolerance(self):
|
||||
"""Test union forwards CadQuery fuzzy tolerance to the OCC kernel."""
|
||||
|
||||
box_left = scad.make_box_rsolid(1.0, 1.0, 1.0, bottom_face_center=(0, 0, 0))
|
||||
box_right = scad.make_box_rsolid(
|
||||
1.0, 1.0, 1.0, bottom_face_center=(1.001, 0, 0)
|
||||
)
|
||||
|
||||
with self.assertRaises(scad.SimpleCADError):
|
||||
scad.union_rsolid(box_left, box_right)
|
||||
with_tol = scad.union_rsolid(box_left, box_right, tol=1e-3)
|
||||
|
||||
self.assertIsInstance(with_tol, scad.Solid)
|
||||
self.assertGreater(with_tol.get_volume(), 2.0)
|
||||
self.assertLess(with_tol.get_volume(), 2.01)
|
||||
|
||||
def test_cut(self):
|
||||
"""Test cut."""
|
||||
result = scad.cut_rsolid(self.box1, self.box2)
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
# 差集体积应该小于原体积
|
||||
self.assertLess(result.get_volume(), self.box1.get_volume())
|
||||
|
||||
def test_intersect(self):
|
||||
"""Test intersect."""
|
||||
result = scad.intersect_rsolid(self.box1, self.box2)
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
# 交集体积应该小于任一体积
|
||||
self.assertLess(result.get_volume(), self.box1.get_volume())
|
||||
self.assertLess(result.get_volume(), self.box2.get_volume())
|
||||
|
||||
def test_boolean_api_names(self):
|
||||
"""Test canonical boolean API names."""
|
||||
self.assertTrue(hasattr(scad, "union_rsolid"))
|
||||
self.assertTrue(hasattr(scad, "cut_rsolid"))
|
||||
self.assertTrue(hasattr(scad, "intersect_rsolid"))
|
||||
|
||||
|
||||
class TestAdvancedFeatures(unittest.TestCase):
|
||||
"""Tests for advanced feature operations."""
|
||||
|
||||
def setUp(self):
|
||||
self.box = scad.make_box_rsolid(2.0, 2.0, 2.0)
|
||||
self.box.auto_tag_faces("box")
|
||||
|
||||
def test_fillet(self):
|
||||
"""Test fillet."""
|
||||
# 获取所有边
|
||||
edges = self.box.get_edges()
|
||||
# 选择前4条边进行圆角
|
||||
selected_edges = edges[:4]
|
||||
|
||||
try:
|
||||
filleted = scad.fillet_rsolid(self.box, selected_edges, 0.2)
|
||||
self.assertIsInstance(filleted, scad.Solid)
|
||||
# 圆角后体积应该稍微减少
|
||||
self.assertLess(filleted.get_volume(), self.box.get_volume())
|
||||
except Exception as e:
|
||||
self.skipTest(f"Fillet operation not fully implemented: {e}")
|
||||
|
||||
def test_chamfer(self):
|
||||
"""Test chamfer."""
|
||||
# 获取所有边
|
||||
edges = self.box.get_edges()
|
||||
# 选择前4条边进行倒角
|
||||
selected_edges = edges[:4]
|
||||
|
||||
try:
|
||||
chamfered = scad.chamfer_rsolid(self.box, selected_edges, 0.2)
|
||||
self.assertIsInstance(chamfered, scad.Solid)
|
||||
# 倒角后体积应该稍微减少
|
||||
self.assertLess(chamfered.get_volume(), self.box.get_volume())
|
||||
except Exception as e:
|
||||
self.skipTest(f"Chamfer operation not fully implemented: {e}")
|
||||
|
||||
def test_shell(self):
|
||||
"""Test shell."""
|
||||
# 获取顶面
|
||||
faces = self.box.get_faces()
|
||||
top_faces = [face for face in faces if "face.top" in scad.list_tags(face)]
|
||||
|
||||
try:
|
||||
shelled = scad.shell_rsolid(self.box, top_faces, 0.2)
|
||||
self.assertIsInstance(shelled, scad.Solid)
|
||||
# 抽壳后体积应该减少
|
||||
self.assertLess(shelled.get_volume(), self.box.get_volume())
|
||||
except Exception as e:
|
||||
self.skipTest(f"Shell operation not fully implemented: {e}")
|
||||
|
||||
def test_loft(self):
|
||||
"""Test loft."""
|
||||
# 创建两个不同大小的矩形轮廓
|
||||
rect1 = scad.create_rectangle_wire(2.0, 2.0, center=(0, 0, 0))
|
||||
rect2 = scad.create_rectangle_wire(1.0, 1.0, center=(0, 0, 2))
|
||||
|
||||
try:
|
||||
lofted = scad.loft_rsolid([rect1, rect2])
|
||||
self.assertIsInstance(lofted, scad.Solid)
|
||||
self.assertGreater(lofted.get_volume(), 0)
|
||||
except Exception as e:
|
||||
self.skipTest(f"Loft operation not fully implemented: {e}")
|
||||
|
||||
def test_linear_pattern(self):
|
||||
"""Test linear pattern."""
|
||||
small_box = scad.create_box(0.5, 0.5, 0.5)
|
||||
|
||||
pattern = scad.linear_pattern_rsolidlist(small_box, (1, 0, 0), 5, 1.0)
|
||||
self.assertIsInstance(pattern, list)
|
||||
# 检查复合体包含5个实体
|
||||
solids = pattern
|
||||
self.assertEqual(len(solids), 5)
|
||||
|
||||
self.assertIsInstance(solids[0], scad.Solid)
|
||||
|
||||
def test_radial_pattern(self):
|
||||
"""Test radial pattern."""
|
||||
small_box = scad.create_box(0.2, 0.2, 1.0, bottom_face_center=(2, 0, 0))
|
||||
|
||||
pattern = scad.radial_pattern_rsolidlist(
|
||||
small_box, (0, 0, 0), (0, 0, 1), 6, 2 * np.pi
|
||||
)
|
||||
self.assertIsInstance(pattern, list)
|
||||
# 检查复合体包含6个实体
|
||||
solids = pattern
|
||||
self.assertEqual(len(solids), 6)
|
||||
|
||||
self.assertIsInstance(solids[0], scad.Solid)
|
||||
|
||||
def test_mirror(self):
|
||||
"""Test mirror."""
|
||||
mirrored = scad.mirror_shape(self.box, (0, 0, 0), (1, 0, 0))
|
||||
self.assertIsInstance(mirrored, scad.Solid)
|
||||
# 镜像后体积应该保持不变
|
||||
if isinstance(mirrored, scad.Solid):
|
||||
self.assertAlmostEqual(
|
||||
mirrored.get_volume(), self.box.get_volume(), places=6
|
||||
)
|
||||
|
||||
|
||||
class TestTagging(unittest.TestCase):
|
||||
"""Tests for the tagging system."""
|
||||
|
||||
def setUp(self):
|
||||
self.box = scad.create_box(1.0, 1.0, 1.0)
|
||||
|
||||
def test_apply_tag(self):
|
||||
"""Test apply tag."""
|
||||
scad.apply_tag(self.box, "test_box")
|
||||
self.assertIn("test_box", scad.list_tags(self.box))
|
||||
|
||||
def test_multiple_tags(self):
|
||||
"""Test multiple tags."""
|
||||
scad.apply_tag(self.box, "tag1")
|
||||
scad.apply_tag(self.box, "tag2")
|
||||
tags = scad.list_tags(self.box)
|
||||
self.assertIn("tag1", tags)
|
||||
self.assertIn("tag2", tags)
|
||||
|
||||
def test_auto_tag_faces_box(self):
|
||||
"""Test auto tag faces box."""
|
||||
self.box.auto_tag_faces("box")
|
||||
faces = self.box.get_faces()
|
||||
|
||||
# 检查是否有标记的面
|
||||
tagged_faces = [face for face in faces if len(scad.list_tags(face)) > 0]
|
||||
self.assertGreater(len(tagged_faces), 0)
|
||||
|
||||
def test_auto_tag_faces_cylinder(self):
|
||||
"""Test auto tag faces cylinder."""
|
||||
cylinder = scad.create_cylinder(1.0, 2.0)
|
||||
cylinder.auto_tag_faces("cylinder")
|
||||
faces = cylinder.get_faces()
|
||||
|
||||
# 检查是否有标记的面
|
||||
tagged_faces = [face for face in faces if len(scad.list_tags(face)) > 0]
|
||||
self.assertGreater(len(tagged_faces), 0)
|
||||
|
||||
def test_auto_tag_faces_sphere(self):
|
||||
"""Test auto tag faces sphere."""
|
||||
sphere = scad.create_sphere(1.0)
|
||||
sphere.auto_tag_faces("sphere")
|
||||
faces = sphere.get_faces()
|
||||
|
||||
# 球体应该只有一个面,且被标记为surface
|
||||
self.assertEqual(len(faces), 1)
|
||||
self.assertIn("face.surface", scad.list_tags(faces[0]))
|
||||
|
||||
|
||||
class TestCoordinateSystem(unittest.TestCase):
|
||||
"""Tests for coordinate system features."""
|
||||
|
||||
def test_world_coordinate_system(self):
|
||||
"""Test world coordinate system."""
|
||||
point = scad.make_point_rvertex(1, 0, 0)
|
||||
# 暂时跳过坐标检查
|
||||
self.assertIsInstance(point, scad.Vertex)
|
||||
|
||||
def test_workplane_translation(self):
|
||||
"""Test workplane translation."""
|
||||
with scad.SimpleWorkplane(origin=(1, 1, 1)):
|
||||
point = scad.make_point_rvertex(1, 0, 0)
|
||||
# 暂时跳过坐标检查
|
||||
self.assertIsInstance(point, scad.Vertex)
|
||||
|
||||
def test_nested_workplane(self):
|
||||
"""Test nested workplane."""
|
||||
with scad.SimpleWorkplane(origin=(1, 0, 0)):
|
||||
with scad.SimpleWorkplane(origin=(0, 1, 0)):
|
||||
point = scad.make_point_rvertex(1, 0, 0)
|
||||
# 暂时跳过坐标检查
|
||||
self.assertIsInstance(point, scad.Vertex)
|
||||
|
||||
|
||||
class TestExport(unittest.TestCase):
|
||||
"""Tests for export functionality."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_export_stl(self):
|
||||
"""Test export STL."""
|
||||
stl_path = os.path.join(self.temp_dir, "test.stl")
|
||||
try:
|
||||
scad.export_stl(self.box, stl_path)
|
||||
# 检查文件是否创建
|
||||
self.assertTrue(os.path.exists(stl_path))
|
||||
# 检查文件是否有内容
|
||||
self.assertGreater(os.path.getsize(stl_path), 0)
|
||||
except Exception as e:
|
||||
self.skipTest(f"STL export not fully implemented: {e}")
|
||||
|
||||
def test_export_step(self):
|
||||
"""Test export STEP."""
|
||||
step_path = os.path.join(self.temp_dir, "test.step")
|
||||
try:
|
||||
scad.export_step(self.box, step_path)
|
||||
# 检查文件是否创建
|
||||
self.assertTrue(os.path.exists(step_path))
|
||||
# 检查文件是否有内容
|
||||
self.assertGreater(os.path.getsize(step_path), 0)
|
||||
except Exception as e:
|
||||
self.skipTest(f"STEP export not fully implemented: {e}")
|
||||
|
||||
def test_export_multiple_shapes(self):
|
||||
"""Test export multiple shapes."""
|
||||
box1 = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
box2 = scad.make_box_rsolid(0.5, 0.5, 0.5, bottom_face_center=(2, 0, 0))
|
||||
stl_path = os.path.join(self.temp_dir, "multiple.stl")
|
||||
|
||||
try:
|
||||
scad.export_stl([box1, box2], stl_path)
|
||||
# 检查文件是否创建
|
||||
self.assertTrue(os.path.exists(stl_path))
|
||||
# 检查文件是否有内容
|
||||
self.assertGreater(os.path.getsize(stl_path), 0)
|
||||
except Exception as e:
|
||||
self.skipTest(f"Multiple shapes export not fully implemented: {e}")
|
||||
|
||||
def test_export_nested_shape_list(self):
|
||||
"""Test export nested shape list."""
|
||||
box = scad.make_box_rsolid(0.8, 0.8, 0.8)
|
||||
cylinder = scad.make_cylinder_rsolid(0.4, 1.0)
|
||||
sphere = scad.make_sphere_rsolid(0.5)
|
||||
|
||||
nested_shapes = [box, [cylinder, sphere]]
|
||||
step_path = os.path.join(self.temp_dir, "nested.step")
|
||||
|
||||
try:
|
||||
scad.export_step(nested_shapes, step_path)
|
||||
self.assertTrue(os.path.exists(step_path))
|
||||
self.assertGreater(os.path.getsize(step_path), 0)
|
||||
except Exception as e:
|
||||
self.skipTest(f"Nested shapes export not fully implemented: {e}")
|
||||
|
||||
def test_export_step_multiple_solids_single_file(self):
|
||||
"""Test export STEP multiple solids single file."""
|
||||
box1 = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
box2 = scad.make_box_rsolid(0.7, 0.7, 0.7, bottom_face_center=(2.0, 0, 0))
|
||||
step_path = os.path.join(self.temp_dir, "assembly_like.step")
|
||||
|
||||
try:
|
||||
scad.export_step([box1, box2], step_path)
|
||||
self.assertTrue(os.path.exists(step_path))
|
||||
self.assertGreater(os.path.getsize(step_path), 0)
|
||||
|
||||
with open(step_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
step_text = f.read()
|
||||
|
||||
# STEP文本中每个实体通常对应一个MANIFOLD_SOLID_BREP定义
|
||||
self.assertGreaterEqual(step_text.count("MANIFOLD_SOLID_BREP"), 2)
|
||||
except Exception as e:
|
||||
self.skipTest(
|
||||
f"Multiple solids in one STEP export not fully implemented: {e}"
|
||||
)
|
||||
|
||||
|
||||
class TestComplexExamples(unittest.TestCase):
|
||||
"""Tests for complex example workflows."""
|
||||
|
||||
def test_create_bracket(self):
|
||||
"""Test create bracket."""
|
||||
# 创建主体
|
||||
base = scad.make_box_rsolid(10, 5, 2)
|
||||
|
||||
# 创建孔
|
||||
hole1 = scad.make_cylinder_rsolid(1, 3, bottom_face_center=(2, 0, 0))
|
||||
hole2 = scad.make_cylinder_rsolid(1, 3, bottom_face_center=(4, 0, 0))
|
||||
|
||||
# 组合
|
||||
bracket = scad.cut_rsolid(base, hole1)
|
||||
bracket = scad.cut_rsolid(bracket, hole2)
|
||||
|
||||
# 添加标签
|
||||
scad.apply_tag(bracket, "bracket")
|
||||
|
||||
# 验证
|
||||
self.assertIsInstance(bracket, scad.Solid)
|
||||
self.assertIn("bracket", scad.list_tags(bracket))
|
||||
self.assertLess(bracket.get_volume(), base.get_volume())
|
||||
|
||||
def test_create_gear_like_shape(self):
|
||||
"""Test create gear like shape."""
|
||||
# 创建基础圆盘
|
||||
base_circle = scad.make_circle_rface((0, 0, 0), 5)
|
||||
gear_base = scad.extrude_rsolid(base_circle, (0, 0, 1), 1)
|
||||
|
||||
# 创建中心孔
|
||||
center_hole = scad.make_cylinder_rsolid(1, 1.5, bottom_face_center=(0, 0, 0.5))
|
||||
gear_base = scad.cut_rsolid(gear_base, center_hole)
|
||||
|
||||
# 创建齿(简化版本)
|
||||
tooth_profile = scad.make_rectangle_rface(0.5, 0.3, center=(5.0, 0, 0))
|
||||
tooth = scad.extrude_rsolid(tooth_profile, (0, 0, 1), 1.2)
|
||||
|
||||
# 合并一个齿到基础上
|
||||
gear = scad.union_rsolid([gear_base, tooth])
|
||||
|
||||
# 验证
|
||||
self.assertIsInstance(gear, scad.Solid)
|
||||
self.assertGreater(gear.get_volume(), gear_base.get_volume())
|
||||
|
||||
def test_create_cone_complex_shape(self):
|
||||
"""Test create cone complex shape."""
|
||||
# 创建基础圆柱体
|
||||
base_cylinder = scad.make_cylinder_rsolid(2.0, 3.0)
|
||||
|
||||
# 创建圆锥体作为顶部
|
||||
cone_top = scad.make_cone_rsolid(2.0, 2.0, 0.5, bottom_face_center=(0, 0, 3.0))
|
||||
|
||||
# 合并圆柱体和圆锥体
|
||||
combined_shape = scad.union_rsolid([base_cylinder, cone_top])
|
||||
|
||||
# 验证
|
||||
self.assertIsInstance(combined_shape, scad.Solid)
|
||||
self.assertGreater(combined_shape.get_volume(), base_cylinder.get_volume())
|
||||
|
||||
# 测试从圆锥体上切割
|
||||
cut_cone = scad.make_cone_rsolid(1.0, 1.5, bottom_face_center=(0, 0, 0))
|
||||
result = scad.cut_rsolid(combined_shape, cut_cone)
|
||||
|
||||
# 验证切割后的体积小于原体积
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
self.assertLess(result.get_volume(), combined_shape.get_volume())
|
||||
|
||||
def test_complex_boolean_operations(self):
|
||||
"""Test complex boolean operations."""
|
||||
# 创建三个重叠的立方体
|
||||
box1 = scad.make_box_rsolid(2, 2, 2, bottom_face_center=(0, 0, 0))
|
||||
box2 = scad.make_box_rsolid(2, 2, 2, bottom_face_center=(1, 0, 0))
|
||||
box3 = scad.make_box_rsolid(2, 2, 2, bottom_face_center=(0, 1, 0))
|
||||
|
||||
# 复合布尔运算:(box1 ∪ box2) ∩ box3
|
||||
union_result = scad.union_rsolid([box1, box2])
|
||||
final_result = scad.intersect_rsolid(union_result, box3)
|
||||
|
||||
# 验证
|
||||
self.assertIsInstance(final_result, scad.Solid)
|
||||
self.assertGreater(final_result.get_volume(), 0)
|
||||
self.assertLess(final_result.get_volume(), box1.get_volume())
|
||||
|
||||
|
||||
class TestErrorHandling(unittest.TestCase):
|
||||
"""Tests for error handling."""
|
||||
|
||||
def test_invalid_dimensions(self):
|
||||
"""Test invalid dimensions."""
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_box_rsolid(-1, 1, 1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_cylinder_rsolid(-1, 1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_sphere_rsolid(-1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_cone_rsolid(-1, 1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_cone_rsolid(1, -1)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.make_cone_rsolid(0, 1)
|
||||
|
||||
def test_invalid_coordinates(self):
|
||||
"""Test invalid coordinates."""
|
||||
# 这些不应该抛出异常,但结果应该是有效的
|
||||
try:
|
||||
_ = scad.make_point_rvertex(float("inf"), 0, 0)
|
||||
# 只要不抛出异常就算通过
|
||||
except Exception as _:
|
||||
pass
|
||||
|
||||
def test_empty_profile_loft(self):
|
||||
"""Test empty profile loft."""
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
scad.loft_rsolid([])
|
||||
except Exception:
|
||||
self.skipTest("Loft operation not fully implemented")
|
||||
|
||||
|
||||
class TestNewFunctionIntegration(unittest.TestCase):
|
||||
"""Tests for integration of newly added functions."""
|
||||
|
||||
def test_spline_with_exact_weights(self):
|
||||
"""Test exact weighted spline."""
|
||||
spline = scad.make_spline_redge(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.6, 1.0, 0.0),
|
||||
(1.4, 1.0, 0.0),
|
||||
(2.0, 0.0, 0.0),
|
||||
],
|
||||
weights=[1.0, 0.75, 0.75, 1.0],
|
||||
)
|
||||
self.assertIsInstance(spline, scad.Edge)
|
||||
|
||||
def test_complex_polyline_shapes(self):
|
||||
"""Test complex polyline shapes."""
|
||||
# 创建一个复杂的星形多段线
|
||||
import math
|
||||
|
||||
star_points = []
|
||||
for i in range(10):
|
||||
angle = i * 2 * math.pi / 10
|
||||
if i % 2 == 0:
|
||||
radius = 2.0
|
||||
else:
|
||||
radius = 1.0
|
||||
x = radius * math.cos(angle)
|
||||
y = radius * math.sin(angle)
|
||||
star_points.append((x, y, 0.0))
|
||||
|
||||
star_wire = scad.make_polyline_rwire(star_points, closed=True)
|
||||
self.assertIsInstance(star_wire, scad.Wire)
|
||||
self.assertTrue(star_wire.is_closed())
|
||||
|
||||
def test_helix_with_different_parameters(self):
|
||||
"""Test helix with different parameters."""
|
||||
# 测试不同的螺旋参数
|
||||
helix1 = scad.make_helix_rwire(0.5, 2.0, 0.3) # 密螺旋
|
||||
helix2 = scad.make_helix_rwire(2.0, 4.0, 1.0) # 疏螺旋
|
||||
helix3 = scad.make_helix_rwire(1.0, 3.0, 0.5, center=(1, 1, 0)) # 偏心螺旋
|
||||
|
||||
self.assertIsInstance(helix1, scad.Wire)
|
||||
self.assertIsInstance(helix2, scad.Wire)
|
||||
self.assertIsInstance(helix3, scad.Wire)
|
||||
|
||||
def test_angle_arc_various_angles(self):
|
||||
"""Test angle arc various angles."""
|
||||
# 90度圆弧
|
||||
arc90 = scad.make_angle_arc_rwire((0, 0, 0), 1.0, 0, np.pi / 2)
|
||||
self.assertIsInstance(arc90, scad.Wire)
|
||||
|
||||
# 180度圆弧
|
||||
arc180 = scad.make_angle_arc_rwire((0, 0, 0), 1.0, 0, np.pi)
|
||||
self.assertIsInstance(arc180, scad.Wire)
|
||||
|
||||
# 270度圆弧
|
||||
arc270 = scad.make_angle_arc_rwire((0, 0, 0), 1.0, 0, 3 * np.pi / 2)
|
||||
self.assertIsInstance(arc270, scad.Wire)
|
||||
|
||||
def test_new_functions_with_extrusion(self):
|
||||
"""Test new functions with extrusion."""
|
||||
# 创建一个复杂轮廓并拉伸
|
||||
points = [(0.0, 0.0, 0.0), (2.0, 0.0, 0.0), (2.0, 1.0, 0.0), (0.0, 1.0, 0.0)]
|
||||
rect_wire = scad.make_polyline_rwire(points, closed=True)
|
||||
|
||||
rect_face = scad.make_face_from_wire_rface(rect_wire)
|
||||
extruded = scad.extrude_rsolid(rect_face, (0, 0, 1), 1.0)
|
||||
self.assertIsInstance(extruded, scad.Solid)
|
||||
self.assertAlmostEqual(extruded.get_volume(), 2.0, places=6)
|
||||
|
||||
def test_alias_functions(self):
|
||||
"""Test alias functions."""
|
||||
# 测试一些主要的别名函数
|
||||
segment = scad.create_segment((0, 0, 0), (1, 0, 0))
|
||||
self.assertIsInstance(segment, scad.Edge)
|
||||
|
||||
arc = scad.create_arc((0, 0, 0), (1, 1, 0), (2, 0, 0))
|
||||
self.assertIsInstance(arc, scad.Edge)
|
||||
|
||||
spline = scad.create_spline(
|
||||
control_points=[(0, 0, 0), (0.6, 1, 0), (1.4, 1, 0), (2, 0, 0)]
|
||||
)
|
||||
self.assertIsInstance(spline, scad.Edge)
|
||||
|
||||
try:
|
||||
helix = scad.create_helix(1.0, 3.0, 0.5)
|
||||
self.assertIsInstance(helix, scad.Edge)
|
||||
except AttributeError:
|
||||
# 如果别名没有正确导出,跳过测试
|
||||
self.skipTest("Alias functions not fully exported")
|
||||
|
||||
|
||||
def run_comprehensive_tests():
|
||||
"""Run the comprehensive test suite."""
|
||||
print("SimpleCAD API 全面单元测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 创建测试套件
|
||||
test_suite = unittest.TestSuite()
|
||||
|
||||
# 添加所有测试类
|
||||
test_classes = [
|
||||
TestBasicShapes,
|
||||
TestNewFunctionIntegration,
|
||||
TestTransformations,
|
||||
Test3DOperations,
|
||||
TestBooleanOperations,
|
||||
TestAdvancedFeatures,
|
||||
TestTagging,
|
||||
TestCoordinateSystem,
|
||||
TestExport,
|
||||
TestComplexExamples,
|
||||
TestErrorHandling,
|
||||
]
|
||||
|
||||
for test_class in test_classes:
|
||||
tests = unittest.TestLoader().loadTestsFromTestCase(test_class)
|
||||
test_suite.addTests(tests)
|
||||
|
||||
# 运行测试
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(test_suite)
|
||||
|
||||
# 测试结果统计
|
||||
print("\n" + "=" * 60)
|
||||
print(f"测试总数: {result.testsRun}")
|
||||
print(
|
||||
f"成功: {result.testsRun - len(result.failures) - len(result.errors) - len(result.skipped)}"
|
||||
)
|
||||
print(f"失败: {len(result.failures)}")
|
||||
print(f"错误: {len(result.errors)}")
|
||||
print(f"跳过: {len(result.skipped)}")
|
||||
|
||||
if result.failures:
|
||||
print("\n失败的测试:")
|
||||
for test, traceback in result.failures:
|
||||
print(f"- {test}: {traceback.split('AssertionError:')[-1].strip()}")
|
||||
|
||||
if result.errors:
|
||||
print("\n错误的测试:")
|
||||
for test, traceback in result.errors:
|
||||
print(f"- {test}: {traceback.split('Exception:')[-1].strip()}")
|
||||
|
||||
if result.skipped:
|
||||
print("\n跳过的测试:")
|
||||
for test, reason in result.skipped:
|
||||
print(f"- {test}: {reason}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
# 返回是否所有测试都通过
|
||||
return len(result.failures) == 0 and len(result.errors) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 确保输出目录存在
|
||||
os.makedirs("output", exist_ok=True)
|
||||
|
||||
# 运行测试
|
||||
success = run_comprehensive_tests()
|
||||
|
||||
if success:
|
||||
print("所有测试通过!SimpleCAD API 功能正常。")
|
||||
else:
|
||||
print("部分测试失败。请检查上述错误信息。")
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,349 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "src/simplecadapi/auto_tools/auto_docs_gen.py"
|
||||
)
|
||||
MODULE_SPEC = importlib.util.spec_from_file_location(
|
||||
"simplecadapi_auto_docs_gen",
|
||||
MODULE_PATH,
|
||||
)
|
||||
if MODULE_SPEC is None or MODULE_SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load module spec for {MODULE_PATH}")
|
||||
|
||||
auto_docs_gen = importlib.util.module_from_spec(MODULE_SPEC)
|
||||
sys.modules[MODULE_SPEC.name] = auto_docs_gen
|
||||
MODULE_SPEC.loader.exec_module(auto_docs_gen)
|
||||
|
||||
|
||||
class TestAutoDocsGenPathResolution(unittest.TestCase):
|
||||
def test_resolve_source_files_from_source_checkout(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
project_root = Path(tmp_dir)
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
"[project]\nname = 'demo'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
module_file = project_root / "src/simplecadapi/auto_tools/auto_docs_gen.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
module_file.write_text("", encoding="utf-8")
|
||||
|
||||
resolved = auto_docs_gen._resolve_source_files(
|
||||
None, module_file=module_file
|
||||
)
|
||||
package_root = project_root / "src/simplecadapi"
|
||||
expected = [
|
||||
(package_root / name).resolve()
|
||||
for name in auto_docs_gen.DEFAULT_SOURCE_FILENAMES
|
||||
]
|
||||
|
||||
self.assertEqual(resolved, expected)
|
||||
|
||||
def test_resolve_source_files_from_site_packages_install(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
venv_root = Path(tmp_dir) / ".venv/lib/python3.12/site-packages"
|
||||
module_file = venv_root / "simplecadapi/auto_tools/auto_docs_gen.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
module_file.write_text("", encoding="utf-8")
|
||||
|
||||
resolved = auto_docs_gen._resolve_source_files(
|
||||
None, module_file=module_file
|
||||
)
|
||||
package_root = venv_root / "simplecadapi"
|
||||
expected = [
|
||||
(package_root / name).resolve()
|
||||
for name in auto_docs_gen.DEFAULT_SOURCE_FILENAMES
|
||||
]
|
||||
|
||||
self.assertEqual(resolved, expected)
|
||||
|
||||
def test_resolve_output_dirs_from_source_checkout_uses_repo_docs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
project_root = Path(tmp_dir)
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
"[project]\nname = 'demo'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
module_file = project_root / "src/simplecadapi/auto_tools/auto_docs_gen.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
module_file.write_text("", encoding="utf-8")
|
||||
|
||||
resolved = auto_docs_gen._resolve_output_dirs(None, module_file=module_file)
|
||||
|
||||
self.assertEqual(resolved, [(project_root / "docs/api").resolve()])
|
||||
|
||||
def test_resolve_output_dirs_from_site_packages_install_uses_cwd(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
|
||||
venv_root = tmp_path / ".venv/lib/python3.12/site-packages"
|
||||
module_file = venv_root / "simplecadapi/auto_tools/auto_docs_gen.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
module_file.write_text("", encoding="utf-8")
|
||||
|
||||
resolved = auto_docs_gen._resolve_output_dirs(
|
||||
None,
|
||||
module_file=module_file,
|
||||
cwd=workspace_root,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, [(workspace_root / "docs/api").resolve()])
|
||||
|
||||
def test_default_source_files_include_v2_public_modules(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
package_root = Path(tmp_dir) / "src/simplecadapi"
|
||||
package_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
resolved = auto_docs_gen._default_source_files(package_root)
|
||||
|
||||
resolved_names = [path.relative_to(package_root).as_posix() for path in resolved]
|
||||
self.assertIn("serializer.py", resolved_names)
|
||||
self.assertIn("graph.py", resolved_names)
|
||||
self.assertIn("expr.py", resolved_names)
|
||||
self.assertIn("sketch.py", resolved_names)
|
||||
self.assertIn("math.py", resolved_names)
|
||||
self.assertIn("translator/freecad_translator/api.py", resolved_names)
|
||||
self.assertIn(
|
||||
"translator/freecad_translator/script_translator.py",
|
||||
resolved_names,
|
||||
)
|
||||
|
||||
def test_default_stdlib_source_files_include_standard_modules(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
package_root = Path(tmp_dir) / "src/simplecadapi"
|
||||
package_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
resolved = auto_docs_gen._default_stdlib_source_files(package_root)
|
||||
|
||||
self.assertEqual(
|
||||
resolved,
|
||||
[
|
||||
package_root / "std/bearing.py",
|
||||
package_root / "std/gear.py",
|
||||
],
|
||||
)
|
||||
|
||||
def test_resolve_stdlib_output_dirs_from_source_checkout_uses_repo_docs(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
project_root = Path(tmp_dir)
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
"[project]\nname = 'demo'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
module_file = project_root / "src/simplecadapi/auto_tools/auto_docs_gen.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
module_file.write_text("", encoding="utf-8")
|
||||
|
||||
resolved = auto_docs_gen._resolve_stdlib_output_dirs(
|
||||
None,
|
||||
module_file=module_file,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, [(project_root / "docs/stdlib").resolve()])
|
||||
|
||||
|
||||
class TestAutoDocsGenExtraction(unittest.TestCase):
|
||||
def test_extract_apis_from_v2_public_modules(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
source_file = tmp_path / "serializer.py"
|
||||
source_file.write_text(
|
||||
"""
|
||||
def export_model_json(session, indent=2):
|
||||
\"\"\"Export the canonical 2.0 model seed JSON.\"\"\"
|
||||
return \"{}\"
|
||||
|
||||
|
||||
def _internal_helper():
|
||||
\"\"\"Should not be documented.\"\"\"
|
||||
return None
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_dir = tmp_path / "docs/api"
|
||||
|
||||
generator = auto_docs_gen.APIDocumentGenerator(
|
||||
source_files=[source_file],
|
||||
output_dirs=[output_dir],
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
apis = generator.extract_apis()
|
||||
|
||||
self.assertEqual([api.name for api in apis], ["export_model_json"])
|
||||
|
||||
def test_generate_markdown_includes_v2_model_api_entry(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
source_file = tmp_path / "serializer.py"
|
||||
source_file.write_text(
|
||||
"""
|
||||
def export_model_json(session, indent=2):
|
||||
\"\"\"Export the canonical 2.0 model seed JSON.
|
||||
|
||||
Args:
|
||||
session: Recorded graph session.
|
||||
indent: JSON indentation level.
|
||||
|
||||
Returns:
|
||||
JSON string representation.
|
||||
\"\"\"
|
||||
return \"{}\"
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_dir = tmp_path / "docs/api"
|
||||
|
||||
generator = auto_docs_gen.APIDocumentGenerator(
|
||||
source_files=[source_file],
|
||||
output_dirs=[output_dir],
|
||||
quiet=True,
|
||||
)
|
||||
generator.extract_apis()
|
||||
generator.generate_markdown_docs()
|
||||
|
||||
readme = (output_dir / "README.md").read_text(encoding="utf-8")
|
||||
page = (output_dir / "export_model_json.md").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("[export_model_json](export_model_json.md)", readme)
|
||||
self.assertIn("def export_model_json(session, indent = 2)", page)
|
||||
self.assertIn("Export the canonical 2.0 model seed JSON.", page)
|
||||
|
||||
def test_generate_markdown_avoids_case_insensitive_filename_collisions(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
source_file = tmp_path / "expr.py"
|
||||
source_file.write_text(
|
||||
"""
|
||||
class Const:
|
||||
\"\"\"Constant node.\"\"\"
|
||||
|
||||
|
||||
def const(value):
|
||||
\"\"\"Constant constructor.\"\"\"
|
||||
return value
|
||||
""".strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_dir = tmp_path / "docs/api"
|
||||
|
||||
generator = auto_docs_gen.APIDocumentGenerator(
|
||||
source_files=[source_file],
|
||||
output_dirs=[output_dir],
|
||||
quiet=True,
|
||||
)
|
||||
generator.extract_apis()
|
||||
generator.generate_markdown_docs()
|
||||
|
||||
readme = (output_dir / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue((output_dir / "Const.md").exists())
|
||||
self.assertTrue((output_dir / "const_function.md").exists())
|
||||
self.assertIn("[Const](Const.md)", readme)
|
||||
self.assertIn("[const](const_function.md)", readme)
|
||||
|
||||
def test_generate_markdown_includes_math_helper_category(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text(
|
||||
"__all__ = ['BSplineFitResult', 'fit_cubic_bspline_control_points']\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
source_file = tmp_path / "math.py"
|
||||
source_file.write_text(
|
||||
'''
|
||||
class BSplineFitResult:
|
||||
"""B-spline fitting result."""
|
||||
|
||||
|
||||
def fit_cubic_bspline_control_points(sample_points, *, tolerance=1e-3):
|
||||
"""Fit sampled points to cubic B-spline controls."""
|
||||
return BSplineFitResult()
|
||||
'''.strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_dir = tmp_path / "docs/api"
|
||||
|
||||
generator = auto_docs_gen.APIDocumentGenerator(
|
||||
source_files=[source_file],
|
||||
output_dirs=[output_dir],
|
||||
quiet=True,
|
||||
)
|
||||
generator.extract_apis()
|
||||
generator.generate_markdown_docs()
|
||||
|
||||
readme = (output_dir / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("## Math Helpers", readme)
|
||||
self.assertIn("[BSplineFitResult](BSplineFitResult.md)", readme)
|
||||
self.assertIn(
|
||||
"[fit_cubic_bspline_control_points](fit_cubic_bspline_control_points.md)",
|
||||
readme,
|
||||
)
|
||||
|
||||
def test_generate_stdlib_markdown_uses_stdlib_index_and_import_surface(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
source_dir = tmp_path / "std"
|
||||
source_dir.mkdir()
|
||||
source_file = source_dir / "gear.py"
|
||||
source_file.write_text(
|
||||
'''
|
||||
def make_spur_gear_rsolid(n_teeth: int, module: float):
|
||||
"""Create a test spur gear.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
n_teeth : int
|
||||
Number of teeth.
|
||||
module : float
|
||||
Gear module.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
def _private_helper():
|
||||
"""Should not be documented."""
|
||||
return None
|
||||
'''.strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output_dir = tmp_path / "docs/stdlib"
|
||||
|
||||
generator = auto_docs_gen.StdlibDocumentGenerator(
|
||||
source_files=[source_file],
|
||||
output_dirs=[output_dir],
|
||||
quiet=True,
|
||||
)
|
||||
generator.extract_apis()
|
||||
generator.generate_markdown_docs()
|
||||
|
||||
readme = (output_dir / "README.md").read_text(encoding="utf-8")
|
||||
page = (output_dir / "make_spur_gear_rsolid.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("# SimpleCAD Standard Library Index", readme)
|
||||
self.assertIn("[make_spur_gear_rsolid](make_spur_gear_rsolid.md)", readme)
|
||||
self.assertIn("scad.std.gear.make_spur_gear_rsolid", page)
|
||||
self.assertIn("**Type**: `int`", page)
|
||||
self.assertNotIn("_private_helper", readme)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
class TestErrorHarness(unittest.TestCase):
|
||||
def test_simplecad_error_is_public(self):
|
||||
self.assertTrue(hasattr(scad, "SimpleCADError"))
|
||||
|
||||
def test_make_box_raises_llm_friendly_english_error(self):
|
||||
with self.assertRaises(scad.SimpleCADError) as ctx:
|
||||
scad.make_box_rsolid(0.0, 1.0, 1.0)
|
||||
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("Operation: make_box_rsolid", message)
|
||||
self.assertIn("Signature: make_box_rsolid(", message)
|
||||
self.assertIn(
|
||||
"Documentation: For full usage details, run help(simplecadapi.make_box_rsolid).",
|
||||
message,
|
||||
)
|
||||
self.assertIn("What happened:", message)
|
||||
self.assertIn("Possible causes:", message)
|
||||
self.assertIn("How to fix:", message)
|
||||
self.assertIn("Technical details:", message)
|
||||
self.assertIn("Failed to create a box solid.", message)
|
||||
self.assertIn("Use width, height, and depth values greater than zero.", message)
|
||||
self.assertNotRegex(message, r"[\u3400-\u9fff\uf900-\ufaff]")
|
||||
|
||||
def test_extrude_raises_actionable_closed_wire_guidance(self):
|
||||
wire = scad.make_segment_rwire((0.0, 0.0, 0.0), (1.0, 0.0, 0.0))
|
||||
|
||||
with self.assertRaises(scad.SimpleCADError) as ctx:
|
||||
scad.extrude_rsolid(wire, (0.0, 0.0, 1.0), 2.0)
|
||||
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("Operation: extrude_rsolid", message)
|
||||
self.assertIn("Signature: extrude_rsolid(", message)
|
||||
self.assertIn("A wire profile was provided but it is not closed.", message)
|
||||
self.assertIn(
|
||||
"If you extrude a wire, make sure the wire is closed or convert it to a face first.",
|
||||
message,
|
||||
)
|
||||
self.assertNotRegex(message, r"[\u3400-\u9fff\uf900-\ufaff]")
|
||||
|
||||
def test_import_graph_json_reports_signature_and_help(self):
|
||||
with self.assertRaises(scad.SimpleCADError) as ctx:
|
||||
scad.import_graph_json('{"schema_version":"1.0","nodes":[],"edges":[]}')
|
||||
|
||||
message = str(ctx.exception)
|
||||
self.assertIn("Operation: import_graph_json", message)
|
||||
self.assertIn("Signature: import_graph_json(", message)
|
||||
self.assertIn(
|
||||
"Documentation: For full usage details, run help(simplecadapi.import_graph_json).",
|
||||
message,
|
||||
)
|
||||
|
||||
def test_vendor_swig_deprecation_warnings_are_suppressed_on_import(self):
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-W",
|
||||
"default",
|
||||
"-c",
|
||||
"import simplecadapi",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
stderr = result.stderr
|
||||
self.assertNotIn("SwigPyPacked", stderr)
|
||||
self.assertNotIn("SwigPyObject", stderr)
|
||||
self.assertNotIn("swigvarlink", stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Focused tests for the 2.0 expression graph layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
class TestExpressionGraph(unittest.TestCase):
|
||||
def test_var_and_expr_roundtrip(self):
|
||||
graph = scad.ExpressionGraph()
|
||||
r = scad.var("r", 10.0)
|
||||
expr = (r + 2) * 3
|
||||
graph.register(expr)
|
||||
|
||||
payload = graph.to_dict()
|
||||
rebuilt = scad.ExpressionGraph.from_dict(payload)
|
||||
|
||||
self.assertEqual(graph.node_count, rebuilt.node_count)
|
||||
self.assertGreaterEqual(rebuilt.node_count, 4)
|
||||
|
||||
def test_expr_evaluates_with_defaults(self):
|
||||
r = scad.var("r", 5.0)
|
||||
expr = r * 2 + 1
|
||||
self.assertAlmostEqual(float(expr), 11.0)
|
||||
|
||||
def test_expression_canonicalization_does_not_promote_discrete_index_lists(self):
|
||||
from simplecadapi.expr import ExpressionGraph, canonicalize_params
|
||||
|
||||
graph = ExpressionGraph()
|
||||
params, param_exprs = canonicalize_params(
|
||||
{
|
||||
"selected_edge_indices": [0, 1, 2],
|
||||
"distance": scad.var("d", 2.0),
|
||||
},
|
||||
graph,
|
||||
)
|
||||
|
||||
self.assertEqual(params["selected_edge_indices"], [0, 1, 2])
|
||||
self.assertTrue(
|
||||
all(isinstance(v, int) for v in params["selected_edge_indices"])
|
||||
)
|
||||
self.assertIn("distance", param_exprs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
"""Tests for Phase 5 (QL sugar) and Phase 6 (DAG session recorder)."""
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import ql as Q
|
||||
from simplecadapi.topology import OperationGraph, OperationNode
|
||||
from simplecadapi.graph import GraphSession, record_operation, get_active_session
|
||||
from simplecadapi.tracking import tracked_cut, tracked_union
|
||||
from simplecadapi.autotag import apply_tracking_tags_to_delta
|
||||
|
||||
|
||||
class TestQLSugar(unittest.TestCase):
|
||||
"""Test QL sugar helpers for tracking-based queries."""
|
||||
|
||||
def test_op_predicate(self):
|
||||
pred = Q.op("cut", "generated")
|
||||
obj = type("Obj", (), {"_tags": {"op.cut.generated"}})()
|
||||
self.assertTrue(pred(obj))
|
||||
|
||||
def test_op_predicate_wildcard(self):
|
||||
pred = Q.op("cut")
|
||||
obj = type("Obj", (), {"_tags": {"op.cut.modified"}})()
|
||||
self.assertTrue(pred(obj))
|
||||
|
||||
def test_origin_predicate(self):
|
||||
pred = Q.origin("tool")
|
||||
obj = type("Obj", (), {"_tags": {"origin.tool"}})()
|
||||
self.assertTrue(pred(obj))
|
||||
|
||||
def test_origin_predicate_no_match(self):
|
||||
pred = Q.origin("tool")
|
||||
obj = type("Obj", (), {"_tags": {"origin.body"}})()
|
||||
self.assertFalse(pred(obj))
|
||||
|
||||
def test_role_predicate(self):
|
||||
pred = Q.role("section")
|
||||
obj = type("Obj", (), {"_tags": {"role.section.face"}})()
|
||||
self.assertTrue(pred(obj))
|
||||
|
||||
def test_select_faces_by_op(self):
|
||||
body = scad.make_box_rsolid(10, 10, 10)
|
||||
body.auto_tag_faces("box")
|
||||
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
|
||||
result = tracked_cut(body, tool)
|
||||
tagged = apply_tracking_tags_to_delta(
|
||||
result.solid, result.delta, result.delta_entries, op="cut"
|
||||
)
|
||||
modified = Q.select(tagged.get_faces()).where(Q.op("cut", "modified")).all()
|
||||
self.assertGreater(len(modified), 0)
|
||||
|
||||
def test_select_faces_by_origin(self):
|
||||
body = scad.make_box_rsolid(10, 10, 10)
|
||||
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
|
||||
result = tracked_cut(body, tool)
|
||||
tagged = apply_tracking_tags_to_delta(
|
||||
result.solid, result.delta, result.delta_entries, op="cut"
|
||||
)
|
||||
tool_faces = Q.select(tagged.get_faces()).where(Q.origin("tool")).all()
|
||||
self.assertGreater(len(tool_faces), 0)
|
||||
|
||||
|
||||
class TestGraphSession(unittest.TestCase):
|
||||
"""Test the DAG session recorder."""
|
||||
|
||||
def test_session_lifecycle(self):
|
||||
session = GraphSession()
|
||||
session.start()
|
||||
self.assertIsNotNone(get_active_session())
|
||||
session.stop()
|
||||
self.assertIsNone(get_active_session())
|
||||
|
||||
def test_record_primitive(self):
|
||||
session = GraphSession()
|
||||
session.start()
|
||||
node = record_operation(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)}
|
||||
)
|
||||
self.assertEqual(node.op, "make_line_redge")
|
||||
self.assertEqual(session.graph.node_count, 1)
|
||||
session.stop()
|
||||
|
||||
def test_record_with_inputs(self):
|
||||
session = GraphSession()
|
||||
session.start()
|
||||
n1 = record_operation(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)}
|
||||
)
|
||||
n2 = record_operation(
|
||||
"make_line_redge", {"start": (10, 0, 0), "end": (10, 10, 0)}
|
||||
)
|
||||
n3 = record_operation(
|
||||
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2]
|
||||
)
|
||||
self.assertEqual(session.graph.node_count, 3)
|
||||
self.assertEqual(len(n3.inputs), 2)
|
||||
session.stop()
|
||||
|
||||
def test_record_with_topo_delta(self):
|
||||
body = scad.make_box_rsolid(10, 10, 10)
|
||||
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
|
||||
result = tracked_cut(body, tool)
|
||||
|
||||
session = GraphSession()
|
||||
session.start()
|
||||
body_node = record_operation(
|
||||
"make_extrude_rsolid",
|
||||
{"direction": (0, 0, 1), "distance": 10.0},
|
||||
)
|
||||
tool_node = record_operation(
|
||||
"make_extrude_rsolid",
|
||||
{"direction": (0, 0, 1), "distance": 15.0},
|
||||
)
|
||||
cut_node = record_operation(
|
||||
"make_cut_rsolid",
|
||||
{},
|
||||
inputs=[body_node, tool_node],
|
||||
topo_delta=result.delta,
|
||||
)
|
||||
self.assertIsNotNone(cut_node.topo_delta)
|
||||
self.assertGreater(len(cut_node.topo_delta.modified), 0)
|
||||
session.stop()
|
||||
|
||||
def test_context_manager(self):
|
||||
with GraphSession() as session:
|
||||
n1 = record_operation(
|
||||
"make_extrude_rsolid", {"direction": (0, 0, 1), "distance": 1.0}
|
||||
)
|
||||
n2 = record_operation(
|
||||
"make_extrude_rsolid", {"direction": (0, 0, 1), "distance": 2.0}
|
||||
)
|
||||
record_operation("make_union_rsolid", {}, inputs=[n1, n2])
|
||||
self.assertEqual(session.graph.node_count, 3)
|
||||
|
||||
def test_no_session_raises(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
record_operation("make_line_redge", {})
|
||||
|
||||
def test_graph_is_dag(self):
|
||||
with GraphSession() as session:
|
||||
n1 = record_operation(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (1, 0, 0)}
|
||||
)
|
||||
n2 = record_operation(
|
||||
"make_line_redge", {"start": (1, 0, 0), "end": (1, 1, 0)}
|
||||
)
|
||||
n3 = record_operation(
|
||||
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2]
|
||||
)
|
||||
self.assertTrue(session.graph.is_dag())
|
||||
|
||||
def test_graph_topological_order(self):
|
||||
with GraphSession() as session:
|
||||
n1 = record_operation(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (1, 0, 0)}
|
||||
)
|
||||
n2 = record_operation(
|
||||
"make_line_redge", {"start": (1, 0, 0), "end": (1, 1, 0)}
|
||||
)
|
||||
n3 = record_operation(
|
||||
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[n1, n2]
|
||||
)
|
||||
n4 = record_operation("make_face_from_wire_rface", {}, inputs=[n3])
|
||||
order = session.graph.topological_order()
|
||||
self.assertEqual(len(order), 4)
|
||||
idx = {node.node_id: i for i, node in enumerate(order)}
|
||||
self.assertLess(idx[n1.node_id], idx[n3.node_id])
|
||||
self.assertLess(idx[n3.node_id], idx[n4.node_id])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,87 @@
|
||||
import simplecadapi as scad
|
||||
import simplecadapi._mesh as _mesh
|
||||
|
||||
|
||||
def _write_cached_mesh_as_obj(solid, path):
|
||||
mesh = _mesh.cached_mesh(solid)
|
||||
assert mesh is not None
|
||||
|
||||
lines = ["# SimpleCAD internal cached mesh OBJ"]
|
||||
for x, y, z in mesh.vertices:
|
||||
lines.append(f"v {x:.9g} {y:.9g} {z:.9g}")
|
||||
for a, b, c in mesh.triangles:
|
||||
lines.append(f"f {int(a) + 1} {int(b) + 1} {int(c) + 1}")
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return mesh
|
||||
|
||||
|
||||
def test_solid_creation_attaches_internal_mesh_cache():
|
||||
solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0)
|
||||
|
||||
mesh = _mesh.cached_mesh(solid)
|
||||
|
||||
assert mesh is not None
|
||||
assert _mesh.mesh_error(solid) is None
|
||||
assert mesh.vertex_count > 0
|
||||
assert mesh.triangle_count > 0
|
||||
assert mesh.vertices.shape[1] == 3
|
||||
assert mesh.triangles.shape[1] == 3
|
||||
assert int(mesh.triangles.min()) >= 0
|
||||
assert int(mesh.triangles.max()) < mesh.vertex_count
|
||||
|
||||
|
||||
def test_internal_mesh_preserves_face_triangle_ranges():
|
||||
solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0)
|
||||
|
||||
mesh = _mesh.cached_mesh(solid)
|
||||
|
||||
assert mesh is not None
|
||||
|
||||
ranges = mesh.face_triangle_ranges
|
||||
assert len(ranges) == len(solid.get_faces())
|
||||
assert sum(item.count for item in ranges) == mesh.triangle_count
|
||||
assert [item.face_index for item in ranges] == list(range(len(ranges)))
|
||||
assert all(item.source_topo_id for item in ranges)
|
||||
|
||||
|
||||
def test_internal_mesh_bounds_track_solid_extent():
|
||||
solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0)
|
||||
|
||||
mesh = _mesh.cached_mesh(solid)
|
||||
assert mesh is not None
|
||||
|
||||
lower, upper = mesh.bounds
|
||||
assert lower == (-5.0, -10.0, 0.0)
|
||||
assert upper == (5.0, 10.0, 30.0)
|
||||
|
||||
|
||||
def test_transformed_solid_keeps_mesh_cache_on_transformed_geometry():
|
||||
solid = scad.make_box_rsolid(width=10.0, height=20.0, depth=30.0)
|
||||
|
||||
translated = scad.translate_shape(shape=solid, vector=(1.0, 2.0, 3.0))
|
||||
mesh = _mesh.cached_mesh(translated)
|
||||
|
||||
assert mesh is not None
|
||||
lower, upper = mesh.bounds
|
||||
assert lower == (-4.0, -8.0, 3.0)
|
||||
assert upper == (6.0, 12.0, 33.0)
|
||||
|
||||
|
||||
def test_mesh_internals_are_not_top_level_public_api():
|
||||
assert not hasattr(scad, "tessellate_rmesh")
|
||||
assert "tessellate_rmesh" not in scad.__all__
|
||||
assert "TriMesh" not in scad.__all__
|
||||
|
||||
|
||||
def test_cached_mesh_can_export_obj_without_public_stl_api(tmp_path):
|
||||
solid = scad.make_cylinder_rsolid(radius=3.0, height=5.0)
|
||||
output_path = tmp_path / "cached_mesh.obj"
|
||||
|
||||
mesh = _write_cached_mesh_as_obj(solid, output_path)
|
||||
|
||||
content = output_path.read_text(encoding="utf-8")
|
||||
vertex_lines = [line for line in content.splitlines() if line.startswith("v ")]
|
||||
face_lines = [line for line in content.splitlines() if line.startswith("f ")]
|
||||
assert len(vertex_lines) == mesh.vertex_count
|
||||
assert len(face_lines) == mesh.triangle_count
|
||||
assert content.startswith("# SimpleCAD internal cached mesh OBJ\n")
|
||||
@@ -0,0 +1,76 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "src/simplecadapi/auto_tools/make_export.py"
|
||||
)
|
||||
MODULE_SPEC = importlib.util.spec_from_file_location(
|
||||
"simplecadapi_make_export",
|
||||
MODULE_PATH,
|
||||
)
|
||||
if MODULE_SPEC is None or MODULE_SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load module spec for {MODULE_PATH}")
|
||||
|
||||
make_export = importlib.util.module_from_spec(MODULE_SPEC)
|
||||
sys.modules[MODULE_SPEC.name] = make_export
|
||||
MODULE_SPEC.loader.exec_module(make_export)
|
||||
|
||||
|
||||
class TestMakeExportInventory(unittest.TestCase):
|
||||
def test_collect_api_inventory_includes_all_supported_modules(self):
|
||||
inventory = make_export.collect_api_inventory()
|
||||
|
||||
self.assertIn("make_box_rsolid", inventory["operations"].functions)
|
||||
self.assertIn("apply_tag", inventory["operations"].functions)
|
||||
self.assertIn("list_tags", inventory["operations"].functions)
|
||||
self.assertNotIn("set_tag", inventory["operations"].functions)
|
||||
self.assertIn("make_n_hole_flange_rsolid", inventory["evolve"].functions)
|
||||
self.assertNotIn("constraints", inventory)
|
||||
self.assertNotIn("field", inventory)
|
||||
self.assertIn("select", inventory["ql"].functions)
|
||||
|
||||
def test_generate_init_file_excludes_removed_modules(self):
|
||||
inventory = make_export.collect_api_inventory()
|
||||
|
||||
content = make_export.generate_init_file(inventory)
|
||||
|
||||
self.assertNotIn("from .constraints import (", content)
|
||||
self.assertIn("from . import ql", content)
|
||||
self.assertIn("from . import translator", content)
|
||||
self.assertNotIn("create_field_surface", content)
|
||||
self.assertIn("make_assembly_rassembly", content)
|
||||
self.assertIn("make_part_rpart", content)
|
||||
self.assertIn("make_material_rmaterial", content)
|
||||
self.assertIn("add_revolute_constraint_rassembly", content)
|
||||
self.assertIn("add_gear_constraint_rassembly", content)
|
||||
self.assertIn("apply_tag", content)
|
||||
self.assertIn("list_tags", content)
|
||||
self.assertNotIn("set_tag", content)
|
||||
self.assertNotIn('"field",', content)
|
||||
self.assertIn('"ql",', content)
|
||||
self.assertIn('"translator",', content)
|
||||
|
||||
def test_target_symbols_include_product_semantics_but_exclude_removed_exports(self):
|
||||
inventory = make_export.collect_api_inventory()
|
||||
|
||||
symbols = make_export._target_symbols(inventory)
|
||||
|
||||
self.assertIn("make_assembly_rassembly", symbols)
|
||||
self.assertIn("make_part_rpart", symbols)
|
||||
self.assertIn("make_material_rmaterial", symbols)
|
||||
self.assertIn("add_prismatic_constraint_rassembly", symbols)
|
||||
self.assertIn("add_rack_pinion_constraint_rassembly", symbols)
|
||||
self.assertNotIn("PartHandle", symbols)
|
||||
self.assertIn("apply_tag", symbols)
|
||||
self.assertIn("list_tags", symbols)
|
||||
self.assertNotIn("set_tag", symbols)
|
||||
self.assertNotIn("field", symbols)
|
||||
self.assertIn("ql", symbols)
|
||||
self.assertIn("translator", symbols)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Tests for public math helper APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import simplecadapi as scad
|
||||
import simplecadapi.math as scmath
|
||||
|
||||
|
||||
def _distance(a: tuple[float, ...], b: tuple[float, ...]) -> float:
|
||||
return math.sqrt(sum((float(x) - float(y)) ** 2 for x, y in zip(a, b)))
|
||||
|
||||
|
||||
def test_fit_line_returns_minimal_cubic_controls() -> None:
|
||||
samples = [(float(i), 0.0, 0.0) for i in range(8)]
|
||||
|
||||
result = scmath.fit_cubic_bspline_control_points(samples, tolerance=1e-10)
|
||||
|
||||
assert result.converged
|
||||
assert result.control_count == 4
|
||||
assert result.dimension == 3
|
||||
assert result.max_error <= 1e-10
|
||||
assert result.unique_knots == (0.0, 1.0)
|
||||
assert result.multiplicities == (4, 4)
|
||||
assert result.evaluate(0.0) == pytest.approx(samples[0])
|
||||
assert result.evaluate(1.0) == pytest.approx(samples[-1])
|
||||
assert result.evaluate(0.5) == pytest.approx((3.5, 0.0, 0.0))
|
||||
|
||||
|
||||
def test_fit_semicircle_adaptively_inserts_simple_knots() -> None:
|
||||
sample_parameters = [i * math.pi / 20.0 for i in range(21)]
|
||||
samples = [(math.cos(t), math.sin(t), 0.0) for t in sample_parameters]
|
||||
|
||||
result = scad.fit_cubic_bspline_control_points(samples, tolerance=0.002)
|
||||
|
||||
assert result.converged
|
||||
assert 4 < result.control_count < len(samples)
|
||||
assert result.max_error <= result.tolerance
|
||||
assert result.multiplicities[0] == 4
|
||||
assert result.multiplicities[-1] == 4
|
||||
assert all(multiplicity == 1 for multiplicity in result.multiplicities[1:-1])
|
||||
|
||||
sample_errors = [
|
||||
_distance(result.evaluate(parameter), sample)
|
||||
for parameter, sample in zip(result.sample_parameters, samples)
|
||||
]
|
||||
assert max(sample_errors) <= result.tolerance
|
||||
|
||||
|
||||
def test_fit_accepts_2d_samples_and_serializes_result() -> None:
|
||||
samples = [(0.0, 0.0), (0.25, 0.2), (0.5, -0.1), (0.75, 0.2), (1.0, 0.0)]
|
||||
|
||||
result = scmath.fit_cubic_bspline_control_points(
|
||||
samples,
|
||||
tolerance=0.05,
|
||||
fairing=1e-3,
|
||||
)
|
||||
payload = result.to_dict()
|
||||
|
||||
assert result.converged
|
||||
assert result.dimension == 2
|
||||
assert payload["degree"] == 3
|
||||
assert payload["control_points"] == [list(point) for point in result.control_points]
|
||||
assert payload["unique_knots"] == list(result.unique_knots)
|
||||
assert payload["multiplicities"] == list(result.multiplicities)
|
||||
assert payload["converged"] is True
|
||||
|
||||
|
||||
def test_fit_removes_consecutive_duplicate_samples() -> None:
|
||||
samples = [(0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (2.0, 0.0, 0.0)]
|
||||
|
||||
result = scmath.fit_cubic_bspline_control_points(samples, tolerance=1e-10)
|
||||
|
||||
assert result.converged
|
||||
assert len(result.sample_parameters) == 3
|
||||
assert result.max_error <= 1e-10
|
||||
|
||||
|
||||
def test_fit_returns_best_result_when_failure_is_allowed() -> None:
|
||||
sample_parameters = [i * math.pi / 20.0 for i in range(21)]
|
||||
samples = [(math.cos(t), math.sin(t), 0.0) for t in sample_parameters]
|
||||
|
||||
result = scmath.fit_cubic_bspline_control_points(
|
||||
samples,
|
||||
tolerance=1e-6,
|
||||
max_control_points=4,
|
||||
raise_on_failure=False,
|
||||
)
|
||||
|
||||
assert not result.converged
|
||||
assert result.control_count == 4
|
||||
assert result.max_error > result.tolerance
|
||||
|
||||
|
||||
def test_fit_raises_when_tolerance_cannot_be_met() -> None:
|
||||
sample_parameters = [i * math.pi / 20.0 for i in range(21)]
|
||||
samples = [(math.cos(t), math.sin(t), 0.0) for t in sample_parameters]
|
||||
|
||||
with pytest.raises(ValueError, match="failed to fit a cubic B-spline"):
|
||||
scmath.fit_cubic_bspline_control_points(
|
||||
samples,
|
||||
tolerance=1e-6,
|
||||
max_control_points=4,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("samples", "match"),
|
||||
[
|
||||
([], "at least two distinct points"),
|
||||
([(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)], "at least two distinct points"),
|
||||
([(0.0, 0.0), (1.0, 0.0, 0.0)], "same dimension"),
|
||||
([(0.0,), (1.0,)], "2D or 3D"),
|
||||
([(0.0, 0.0), (math.nan, 1.0)], "finite"),
|
||||
],
|
||||
)
|
||||
def test_fit_validates_sample_points(samples: list[tuple[float, ...]], match: str) -> None:
|
||||
with pytest.raises(ValueError, match=match):
|
||||
scmath.fit_cubic_bspline_control_points(samples)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "match"),
|
||||
[
|
||||
({"tolerance": 0.0}, "tolerance"),
|
||||
({"fairing": -1.0}, "fairing"),
|
||||
({"duplicate_tolerance": -1.0}, "duplicate_tolerance"),
|
||||
({"knot_tolerance": 0.0}, "knot_tolerance"),
|
||||
({"max_control_points": 3}, "max_control_points"),
|
||||
],
|
||||
)
|
||||
def test_fit_validates_options(kwargs: dict[str, float], match: str) -> None:
|
||||
samples = [(0.0, 0.0, 0.0), (0.5, 0.1, 0.0), (1.0, 0.0, 0.0)]
|
||||
|
||||
with pytest.raises(ValueError, match=match):
|
||||
scmath.fit_cubic_bspline_control_points(samples, **kwargs)
|
||||
|
||||
|
||||
def test_math_helper_is_public_through_top_level_and_submodule() -> None:
|
||||
assert scad.fit_cubic_bspline_control_points is scmath.fit_cubic_bspline_control_points
|
||||
assert scad.BSplineFitResult is scmath.BSplineFitResult
|
||||
assert scad.math is scmath
|
||||
assert "math" in scad.__all__
|
||||
assert "fit_cubic_bspline_control_points" in scad.__all__
|
||||
|
||||
|
||||
def test_fit_result_fields_feed_exact_spline_builder() -> None:
|
||||
samples = [(0.0, 0.0, 0.0), (1.0, 0.6, 0.0), (2.0, 0.0, 0.0)]
|
||||
fit = scmath.fit_cubic_bspline_control_points(samples, tolerance=0.01)
|
||||
|
||||
edge = scad.make_spline_redge(
|
||||
control_points=fit.control_points,
|
||||
knots=fit.unique_knots,
|
||||
multiplicities=fit.multiplicities,
|
||||
)
|
||||
|
||||
assert isinstance(edge, scad.Edge)
|
||||
metadata = edge.get_metadata("geo")
|
||||
assert metadata["type"] == "bspline"
|
||||
assert metadata["degree"] == fit.degree
|
||||
assert metadata["knots"] == list(fit.unique_knots)
|
||||
assert metadata["multiplicities"] == list(fit.multiplicities)
|
||||
|
||||
|
||||
def test_exact_spline_builder_accepts_full_repeated_knot_vector() -> None:
|
||||
edge = scad.make_spline_redge(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.5, 1.0, 0.0),
|
||||
(1.5, 1.0, 0.0),
|
||||
(2.0, 0.0, 0.0),
|
||||
],
|
||||
knots=[0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
|
||||
)
|
||||
|
||||
metadata = edge.get_metadata("geo")
|
||||
assert metadata["knots"] == [0.0, 1.0]
|
||||
assert metadata["multiplicities"] == [4, 4]
|
||||
|
||||
|
||||
def test_exact_spline_builder_validates_exact_payload() -> None:
|
||||
with pytest.raises(ValueError, match=r"sum\(multiplicities\)"):
|
||||
scad.make_spline_redge(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.5, 1.0, 0.0),
|
||||
(1.5, 1.0, 0.0),
|
||||
(2.0, 0.0, 0.0),
|
||||
],
|
||||
knots=[0.0, 1.0],
|
||||
multiplicities=[3, 3],
|
||||
)
|
||||
|
||||
|
||||
def test_exact_spline_graph_payload_uses_control_parameters() -> None:
|
||||
with scad.GraphSession() as session:
|
||||
scad.make_spline_redge(
|
||||
control_points=[
|
||||
(0.0, 0.0),
|
||||
(0.5, 1.0),
|
||||
(1.5, 1.0),
|
||||
(2.0, 0.0),
|
||||
]
|
||||
)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
node = next(node for node in payload["graph"]["nodes"] if node["op"] == "make_spline_redge")
|
||||
|
||||
assert "control_points" in node["params"]
|
||||
assert "points" not in node["params"]
|
||||
assert node["params"]["degree"] == 3
|
||||
assert node["params"]["knots"] == [0.0, 1.0]
|
||||
assert node["params"]["multiplicities"] == [4, 4]
|
||||
replayed = scad.replay_model_json(json.dumps(payload))
|
||||
assert len(replayed) == 1
|
||||
assert isinstance(replayed[0], scad.Edge)
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Focused tests for canonical 2.0 model JSON export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.graph import GraphSession
|
||||
|
||||
|
||||
class TestModelJson(unittest.TestCase):
|
||||
def test_model_json_contains_graph_and_expression_graph(self):
|
||||
r = scad.var("r", 2.0)
|
||||
with GraphSession() as session:
|
||||
scad.make_circle_rface((0, 0, 0), r)
|
||||
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("graph", payload)
|
||||
self.assertIn("expression_graph", payload)
|
||||
self.assertIn("canonical_contract", payload)
|
||||
self.assertGreaterEqual(payload["graph"].node_count, 1)
|
||||
self.assertGreaterEqual(payload["expression_graph"].node_count, 1)
|
||||
|
||||
def test_model_json_declares_canonical_contract_and_graph_roles(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(1.0, 2.0, 3.0)
|
||||
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("canonical_contract", payload)
|
||||
contract = payload["canonical_contract"]
|
||||
self.assertEqual(contract["contract_version"], "2.0")
|
||||
self.assertEqual(contract["graph_roles"]["graph"], "canonical_low_level_graph")
|
||||
self.assertEqual(contract["graph_roles"]["leaf_ids"], "explicit_result_set")
|
||||
self.assertEqual(contract["replay_policy"]["preferred_graph"], "graph")
|
||||
self.assertIn("core_op_set", contract)
|
||||
self.assertGreaterEqual(len(contract["core_op_set"]), 1)
|
||||
self.assertEqual(contract["replay_policy"]["default_mode"], "strict")
|
||||
self.assertEqual(
|
||||
contract["replay_policy"]["permissive_mode"], "explicit_opt_in"
|
||||
)
|
||||
|
||||
def test_model_json_includes_geometry_and_delta_registries(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
||||
scad.translate_shape(box, (1.0, 0.0, 0.0))
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("geometry_registry", payload)
|
||||
self.assertIn("semantic_delta_log", payload)
|
||||
self.assertIn("topology_delta_log", payload)
|
||||
self.assertGreaterEqual(len(payload["geometry_registry"]), 1)
|
||||
self.assertGreaterEqual(len(payload["semantic_delta_log"]), 1)
|
||||
|
||||
def test_model_json_includes_sketch_registry_without_assembly_fields(self):
|
||||
with GraphSession() as session:
|
||||
face = scad.make_circle_rface((0, 0, 0), scad.var("r", 2.0))
|
||||
scad.extrude_rsolid(face, (0, 0, 1), 3.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("sketch_profile_registry", payload)
|
||||
self.assertGreaterEqual(len(payload["sketch_profile_registry"]), 1)
|
||||
self.assertNotIn("assembly", payload)
|
||||
self.assertNotIn("assembly_registry", payload)
|
||||
self.assertNotIn("constraint_registry", payload)
|
||||
|
||||
def test_model_json_import_preserves_registry_payloads(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
||||
scad.translate_shape(box, (1.0, 0.0, 0.0))
|
||||
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("geometry_registry", payload)
|
||||
self.assertIn("canonical_contract", payload)
|
||||
self.assertIn("semantic_delta_log", payload)
|
||||
self.assertIn("topology_delta_log", payload)
|
||||
self.assertGreaterEqual(len(payload["geometry_registry"]), 1)
|
||||
|
||||
def test_model_json_import_preserves_supported_extended_registries(self):
|
||||
with GraphSession() as session:
|
||||
face = scad.make_circle_rface((0, 0, 0), 2.0)
|
||||
scad.extrude_rsolid(face, (0, 0, 1), 3.0)
|
||||
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("sketch_profile_registry", payload)
|
||||
self.assertGreaterEqual(len(payload["sketch_profile_registry"]), 1)
|
||||
self.assertNotIn("assembly", payload)
|
||||
self.assertNotIn("assembly_registry", payload)
|
||||
self.assertNotIn("constraint_registry", payload)
|
||||
|
||||
def test_model_json_graph_contains_only_low_level_ops(self):
|
||||
with GraphSession() as session:
|
||||
profile = scad.make_rectangle_rwire(0.4, 0.2)
|
||||
scad.helical_sweep_rsolid(profile, pitch=1.0, height=2.0, radius=1.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("graph", payload)
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
self.assertIn("make_helix_redge", core_ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", core_ops)
|
||||
self.assertIn("make_sweep_rsolid", core_ops)
|
||||
self.assertNotIn("helical_sweep", core_ops)
|
||||
|
||||
def test_model_json_can_replay_without_graph_json_api(self):
|
||||
with GraphSession() as session:
|
||||
face = scad.make_circle_rface((0, 0, 0), 1.2)
|
||||
original = scad.extrude_rsolid(face, (0, 0, 1), 2.5)
|
||||
|
||||
replayed = scad.replay_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertEqual(len(replayed), 1)
|
||||
self.assertIsInstance(replayed[0], scad.Solid)
|
||||
self.assertAlmostEqual(
|
||||
replayed[0].get_volume(), original.get_volume(), places=5
|
||||
)
|
||||
|
||||
def test_model_json_replay_uses_single_low_level_graph_for_macro_ops(self):
|
||||
with GraphSession() as session:
|
||||
profile = scad.make_rectangle_rwire(0.4, 0.2)
|
||||
original = scad.helical_sweep_rsolid(
|
||||
profile, pitch=1.0, height=2.0, radius=1.0
|
||||
)
|
||||
|
||||
replayed = scad.replay_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertEqual(len(replayed), 1)
|
||||
self.assertIsInstance(replayed[0], scad.Solid)
|
||||
self.assertAlmostEqual(
|
||||
replayed[0].get_volume(), original.get_volume(), places=4
|
||||
)
|
||||
|
||||
def test_graph_records_make_box_as_direct_primitive(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(2.0, 3.0, 4.0, bottom_face_center=(1.0, 2.0, 3.0))
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertEqual(core_ops, ["make_box_rsolid"])
|
||||
|
||||
def test_graph_lowers_make_circle_face_to_wire_plus_face(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_circle_rface((0.0, 0.0, 0.0), 2.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertIn("make_circle_redge", core_ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", core_ops)
|
||||
self.assertIn("make_face_from_wire_rface", core_ops)
|
||||
self.assertNotIn("make_circle_face", core_ops)
|
||||
|
||||
def test_graph_records_make_cylinder_as_direct_primitive(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_cylinder_rsolid(1.0, 3.0, bottom_face_center=(0.0, 0.0, 0.0))
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertEqual(core_ops, ["make_cylinder_rsolid"])
|
||||
|
||||
def test_graph_box_primitive_does_not_emit_rectangle_profile(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(2.0, 3.0, 4.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertEqual(core_ops, ["make_box_rsolid"])
|
||||
self.assertNotIn("make_rectangle_face", core_ops)
|
||||
|
||||
def test_graph_records_make_sphere_as_direct_primitive(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_sphere_rsolid(2.0, center=(0.0, 0.0, 0.0))
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertEqual(core_ops, ["make_sphere_rsolid"])
|
||||
|
||||
def test_graph_records_make_cone_as_direct_primitive(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_cone_rsolid(2.0, 4.0, top_radius=0.5)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertEqual(core_ops, ["make_cone_rsolid"])
|
||||
|
||||
def test_graph_lowers_rectangle_wire_to_lines_plus_wire_assembly(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_rectangle_rwire(2.0, 3.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertIn("make_line_redge", core_ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", core_ops)
|
||||
self.assertNotIn("make_rectangle_wire", core_ops)
|
||||
|
||||
def test_graph_lowers_circle_wire_to_edge_plus_wire_assembly(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_circle_rwire((0.0, 0.0, 0.0), 2.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertIn("make_circle_redge", core_ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", core_ops)
|
||||
self.assertNotIn("make_circle_wire", core_ops)
|
||||
|
||||
def test_graph_lowers_linear_pattern_to_explicit_transforms(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
scad.linear_pattern_rsolidlist(box, (1.0, 0.0, 0.0), 4, 1.5)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertIn("make_translate_rshape", core_ops)
|
||||
self.assertNotIn("linear_pattern", core_ops)
|
||||
|
||||
def test_graph_lowers_radial_pattern_to_explicit_rotates(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
scad.radial_pattern_rsolidlist(
|
||||
box, (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 4, 360.0
|
||||
)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertIn("make_rotate_rshape", core_ops)
|
||||
self.assertNotIn("radial_pattern", core_ops)
|
||||
|
||||
def test_graph_lowers_remaining_wire_convenience_ops(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_polyline_rwire(
|
||||
[(0.0, 0.0, 0.0), (1.0, 0.2, 0.0), (2.0, 0.0, 0.0)]
|
||||
)
|
||||
scad.make_segment_rwire((0.0, 0.0, 0.0), (1.0, 0.0, 0.0))
|
||||
scad.make_three_point_arc_rwire(
|
||||
(0.0, 0.0, 0.0), (1.0, 1.0, 0.0), (2.0, 0.0, 0.0)
|
||||
)
|
||||
scad.make_angle_arc_rwire((0.0, 0.0, 0.0), 1.0, 0.0, 1.57)
|
||||
scad.make_spline_rwire(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.6, 1.0, 0.0),
|
||||
(1.4, 1.0, 0.0),
|
||||
(2.0, 0.0, 0.0),
|
||||
]
|
||||
)
|
||||
scad.make_helix_rwire(1.0, 2.0, 0.8)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertIn("make_line_redge", core_ops)
|
||||
self.assertIn("make_three_point_arc_redge", core_ops)
|
||||
self.assertIn("make_angle_arc_redge", core_ops)
|
||||
self.assertIn("make_spline_redge", core_ops)
|
||||
self.assertIn("make_helix_redge", core_ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", core_ops)
|
||||
self.assertNotIn("make_polyline_wire", core_ops)
|
||||
self.assertNotIn("make_segment_wire", core_ops)
|
||||
self.assertNotIn("make_three_point_arc_wire", core_ops)
|
||||
self.assertNotIn("make_angle_arc_wire", core_ops)
|
||||
self.assertNotIn("make_spline_wire", core_ops)
|
||||
self.assertNotIn("make_helix_wire", core_ops)
|
||||
|
||||
def test_graph_lowers_spline_wire_to_exact_spline_edge_plus_wire_assembly(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_spline_rwire(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
],
|
||||
periodic=True,
|
||||
)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
core_ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
self.assertIn("make_spline_redge", core_ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", core_ops)
|
||||
self.assertNotIn("make_spline_wire", core_ops)
|
||||
|
||||
def test_graph_preserves_explicit_selected_refs_for_detail_features(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
scad.fillet_rsolid(box, [box.get_edges(i) for i in range(2)], 0.3)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
fillet_nodes = [
|
||||
node
|
||||
for node in payload["graph"]["nodes"]
|
||||
if node["op"] == "make_fillet_rsolid"
|
||||
]
|
||||
|
||||
self.assertEqual(len(fillet_nodes), 1)
|
||||
fillet_params = fillet_nodes[0]["params"]
|
||||
self.assertIn("selected_edges", fillet_params)
|
||||
self.assertGreaterEqual(len(fillet_params["selected_edges"]), 1)
|
||||
self.assertIn("topo_id", fillet_params["selected_edges"][0])
|
||||
|
||||
def test_graph_ops_stay_within_declared_canonical_op_set(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(2.0, 3.0, 4.0)
|
||||
scad.make_cylinder_rsolid(1.0, 3.0)
|
||||
scad.make_sphere_rsolid(1.5)
|
||||
profile = scad.make_rectangle_rwire(0.4, 0.2)
|
||||
scad.helical_sweep_rsolid(profile, pitch=1.0, height=2.0, radius=1.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
contract = payload["canonical_contract"]
|
||||
core_ops = {node["op"] for node in payload["graph"]["nodes"]}
|
||||
|
||||
self.assertTrue(core_ops.issubset(set(contract["core_op_set"])))
|
||||
self.assertIn("make_box_rsolid", core_ops)
|
||||
self.assertIn("make_cylinder_rsolid", core_ops)
|
||||
self.assertIn("make_sphere_rsolid", core_ops)
|
||||
self.assertNotIn("helical_sweep", core_ops)
|
||||
self.assertNotIn("make_polyline_wire", core_ops)
|
||||
|
||||
def test_sketch_profile_registry_uses_canonical_op_names(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_circle_rface((0.0, 0.0, 0.0), 1.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
registry_ops = {entry["op"] for entry in payload["sketch_profile_registry"]}
|
||||
canonical_ops = set(payload["canonical_contract"]["core_op_set"])
|
||||
|
||||
self.assertTrue(registry_ops)
|
||||
self.assertTrue(registry_ops.issubset(canonical_ops))
|
||||
|
||||
def test_model_json_import_rejects_legacy_graph_op(self):
|
||||
payload = {
|
||||
"schema_version": "2.0",
|
||||
"canonical_contract": {"contract_version": "2.0"},
|
||||
"graph": {
|
||||
"schema_version": "2.0",
|
||||
"graph_id": "test",
|
||||
"nodes": [
|
||||
{
|
||||
"node_id": "n1",
|
||||
"op": "make_legacy_box",
|
||||
"params": {"w": 1, "h": 1, "d": 1},
|
||||
"inputs": [],
|
||||
"output_count": 1,
|
||||
"tags": [],
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
"leaf_ids": ["n1"],
|
||||
"expression_graph": {"nodes": []},
|
||||
"frame_graph": {"nodes": []},
|
||||
}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
scad.import_model_json(json.dumps(payload))
|
||||
|
||||
def test_graph_selection_refs_follow_declared_schema(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
scad.fillet_rsolid(box, [box.get_edges(i) for i in range(2)], 0.3)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
contract = payload["canonical_contract"]
|
||||
selection_schema = contract["selection_ref_schema"]
|
||||
fillet_node = next(
|
||||
node
|
||||
for node in payload["graph"]["nodes"]
|
||||
if node["op"] == "make_fillet_rsolid"
|
||||
)
|
||||
selected_edge_ref = fillet_node["params"]["selected_edges"][0]
|
||||
|
||||
self.assertEqual(
|
||||
selection_schema["replay_resolution_order"],
|
||||
[
|
||||
"geo_select_nodes",
|
||||
"selection_query",
|
||||
"explicit_topo_refs",
|
||||
"stable_indices",
|
||||
"selector_hint",
|
||||
],
|
||||
)
|
||||
self.assertEqual(selection_schema["edge_param"], "selected_edges")
|
||||
self.assertEqual(selection_schema["face_param"], "selected_faces")
|
||||
self.assertTrue(
|
||||
set(selection_schema["required_topo_ref_fields"]).issubset(
|
||||
selected_edge_ref.keys()
|
||||
)
|
||||
)
|
||||
self.assertEqual(selected_edge_ref["kind"], "EDGE")
|
||||
self.assertIn("selector_hint", selected_edge_ref)
|
||||
|
||||
def test_model_json_replay_prefers_geo_select_nodes_over_selected_refs(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
original = scad.fillet_rsolid(box, scad.ql.edges().take(1).exactly(1), 0.2)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
fillet_node = next(
|
||||
node
|
||||
for node in payload["graph"]["nodes"]
|
||||
if node["op"] == "make_fillet_rsolid"
|
||||
)
|
||||
self.assertNotIn("selection_query", fillet_node["params"])
|
||||
self.assertEqual(len(fillet_node["params"]["selected_edge_node_ids"]), 1)
|
||||
fillet_node["params"]["selected_edges"] = []
|
||||
fillet_node["params"]["selected_edge_indices"] = []
|
||||
fillet_node["params"]["edge_count"] = 1
|
||||
|
||||
replayed = scad.replay_model_json(json.dumps(payload))
|
||||
|
||||
self.assertEqual(len(replayed), 1)
|
||||
self.assertIsInstance(replayed[0], scad.Solid)
|
||||
self.assertAlmostEqual(
|
||||
replayed[0].get_volume(), original.get_volume(), places=5
|
||||
)
|
||||
|
||||
def test_model_json_replay_preserves_linear_pattern_multi_output(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
originals = scad.linear_pattern_rsolidlist(box, (1.0, 0.0, 0.0), 4, 1.5)
|
||||
|
||||
replayed = scad.replay_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertEqual(len(replayed), 4)
|
||||
self.assertAlmostEqual(
|
||||
sum(shape.get_volume() for shape in replayed),
|
||||
sum(shape.get_volume() for shape in originals),
|
||||
places=5,
|
||||
)
|
||||
|
||||
def test_model_json_replay_preserves_radial_pattern_multi_output(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
originals = scad.radial_pattern_rsolidlist(
|
||||
box, (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 4, 360.0
|
||||
)
|
||||
|
||||
replayed = scad.replay_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertEqual(len(replayed), 4)
|
||||
self.assertAlmostEqual(
|
||||
sum(shape.get_volume() for shape in replayed),
|
||||
sum(shape.get_volume() for shape in originals),
|
||||
places=5,
|
||||
)
|
||||
|
||||
|
||||
class TestOperationGraphDeltaSerialization(unittest.TestCase):
|
||||
def test_graph_json_roundtrip_preserves_semantic_delta(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertIsNotNone(leaf.semantic_delta)
|
||||
self.assertGreaterEqual(len(leaf.semantic_delta.created), 1)
|
||||
|
||||
def test_graph_json_roundtrip_preserves_topology_delta(self):
|
||||
with GraphSession() as session:
|
||||
body = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
tool = scad.make_cylinder_rsolid(
|
||||
0.75, 6.0, bottom_face_center=(0.0, 0.0, -1.0)
|
||||
)
|
||||
scad.cut_rsolid(body, tool)
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_multi_tool_cut_topology_delta_keeps_step_chain(self):
|
||||
with GraphSession() as session:
|
||||
body = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
tool_a = scad.make_box_rsolid(
|
||||
1.0, 1.0, 5.0, bottom_face_center=(-0.75, 0.0, -0.5)
|
||||
)
|
||||
tool_b = scad.make_box_rsolid(
|
||||
1.0, 1.0, 5.0, bottom_face_center=(0.75, 0.0, -0.5)
|
||||
)
|
||||
scad.cut_rsolid(body, tool_a, tool_b)
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertEqual(leaf.op, "make_cut_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertEqual(len(leaf.topo_delta.raw_event["steps"]), 2)
|
||||
|
||||
def test_multi_tool_intersect_topology_delta_keeps_step_chain(self):
|
||||
with GraphSession() as session:
|
||||
body = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
tool_a = scad.make_box_rsolid(
|
||||
4.0, 4.0, 4.0, bottom_face_center=(1.0, 0.0, 0.0)
|
||||
)
|
||||
tool_b = scad.make_box_rsolid(
|
||||
4.0, 4.0, 4.0, bottom_face_center=(0.0, 1.0, 0.0)
|
||||
)
|
||||
scad.intersect_rsolid(body, tool_a, tool_b)
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertEqual(leaf.op, "make_intersect_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertEqual(len(leaf.topo_delta.raw_event["steps"]), 2)
|
||||
|
||||
def test_semantic_delta_created_refs_are_bound_to_real_graph_and_node_ids(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertIsNotNone(leaf.semantic_delta)
|
||||
self.assertGreaterEqual(len(leaf.semantic_delta.created), 1)
|
||||
|
||||
for ref in leaf.semantic_delta.created:
|
||||
with self.subTest(ref=ref):
|
||||
self.assertEqual(ref.graph_id, session.graph.graph_id)
|
||||
self.assertEqual(ref.node_id, leaf.node_id)
|
||||
self.assertNotEqual(ref.graph_id, "pending")
|
||||
self.assertNotEqual(ref.node_id, "pending")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Integration tests proving tracking/graphing is seamless in original APIs."""
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.graph import GraphSession
|
||||
from simplecadapi import ql as Q
|
||||
|
||||
|
||||
class TestOriginalBooleanApiIntegration(unittest.TestCase):
|
||||
def test_cut_rsolid_auto_applies_semantic_tags(self):
|
||||
body = scad.make_box_rsolid(10, 10, 10)
|
||||
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
|
||||
|
||||
result = scad.cut_rsolid(body, tool)
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
|
||||
faces = result.get_faces()
|
||||
modified = Q.select(faces).where(Q.op("cut", "modified")).all()
|
||||
preserved = Q.select(faces).where(Q.op("cut", "preserved")).all()
|
||||
tool_faces = Q.select(faces).where(Q.origin("tool")).all()
|
||||
|
||||
self.assertGreaterEqual(len(modified), 0)
|
||||
self.assertGreater(len(tool_faces), 0)
|
||||
self.assertEqual(result.get_metadata("track")["op"], "make_cut_rsolid")
|
||||
|
||||
def test_intersect_rsolid_auto_applies_semantic_tags(self):
|
||||
a = scad.make_box_rsolid(10, 10, 10)
|
||||
b = scad.make_cylinder_rsolid(4.0, 10.0, bottom_face_center=(3, 3, 0))
|
||||
|
||||
result = scad.intersect_rsolid(a, b)
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
|
||||
faces = result.get_faces()
|
||||
tagged = Q.select(faces).where(Q.op("intersect")).all()
|
||||
self.assertGreaterEqual(len(tagged), 0)
|
||||
self.assertEqual(
|
||||
result.get_metadata("track")["op"], "make_intersect_rsolid"
|
||||
)
|
||||
|
||||
def test_union_rsolid_auto_applies_semantic_tags(self):
|
||||
a = scad.make_box_rsolid(10, 10, 10)
|
||||
b = scad.make_cylinder_rsolid(4.0, 10.0, bottom_face_center=(3, 3, 0))
|
||||
|
||||
result = scad.union_rsolid(a, b)
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
|
||||
faces = result.get_faces()
|
||||
tagged = Q.select(faces).where(Q.op("union")).all()
|
||||
self.assertGreaterEqual(len(tagged), 0)
|
||||
self.assertEqual(result.get_metadata("track")["op"], "make_union_rsolid")
|
||||
|
||||
|
||||
class TestOriginalTransformApiIntegration(unittest.TestCase):
|
||||
def test_translate_shape_auto_applies_track_metadata(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
moved = scad.translate_shape(box, (1, 0, 0))
|
||||
|
||||
self.assertIsInstance(moved, scad.Solid)
|
||||
self.assertEqual(moved.get_metadata("track")["op"], "make_translate_rshape")
|
||||
faces = moved.get_faces()
|
||||
self.assertGreater(
|
||||
len(
|
||||
Q.select(faces).where(Q.op("make_translate_rshape", "preserved")).all()
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_rotate_shape_auto_applies_track_metadata(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
moved = scad.rotate_shape(box, 45.0, (0, 0, 1))
|
||||
|
||||
self.assertIsInstance(moved, scad.Solid)
|
||||
self.assertEqual(moved.get_metadata("track")["op"], "make_rotate_rshape")
|
||||
faces = moved.get_faces()
|
||||
self.assertGreater(
|
||||
len(Q.select(faces).where(Q.op("make_rotate_rshape", "preserved")).all()),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
class TestOriginalFeatureApiIntegration(unittest.TestCase):
|
||||
def test_extrude_rsolid_auto_applies_semantic_tags(self):
|
||||
profile = scad.make_rectangle_rface(2.0, 1.0)
|
||||
extruded = scad.extrude_rsolid(profile, (0, 0, 1), 2.0)
|
||||
|
||||
self.assertEqual(extruded.get_metadata("track")["op"], "make_extrude_rsolid")
|
||||
faces = extruded.get_faces()
|
||||
tagged = Q.select(faces).where(Q.op("make_extrude_rsolid")).all()
|
||||
self.assertGreater(len(tagged), 0)
|
||||
|
||||
def test_fillet_rsolid_auto_applies_semantic_tags(self):
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
edges = [box.get_edges(i) for i in range(4)]
|
||||
filleted = scad.fillet_rsolid(box, edges, 0.2)
|
||||
|
||||
self.assertEqual(filleted.get_metadata("track")["op"], "make_fillet_rsolid")
|
||||
faces = filleted.get_faces()
|
||||
tagged = Q.select(faces).where(Q.op("make_fillet_rsolid")).all()
|
||||
self.assertGreater(len(tagged), 0)
|
||||
|
||||
def test_shell_rsolid_auto_applies_track_metadata(self):
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
faces_to_remove = [box.get_faces(0)]
|
||||
shelled = scad.shell_rsolid(box, faces_to_remove, 0.2)
|
||||
|
||||
self.assertEqual(shelled.get_metadata("track")["op"], "make_shell_rsolid")
|
||||
|
||||
def test_loft_rsolid_auto_applies_track_metadata(self):
|
||||
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0, 0, 0))
|
||||
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0, 0, 2.0))
|
||||
lofted = scad.loft_rsolid([a, b])
|
||||
|
||||
self.assertEqual(lofted.get_metadata("track")["op"], "make_loft_rsolid")
|
||||
|
||||
|
||||
class TestOriginalApiGraphRecording(unittest.TestCase):
|
||||
def test_original_apis_record_graph_automatically(self):
|
||||
with GraphSession() as session:
|
||||
body = scad.make_box_rsolid(10, 10, 10)
|
||||
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
|
||||
result = scad.cut_rsolid(body, tool)
|
||||
|
||||
graph = session.graph
|
||||
self.assertGreaterEqual(graph.node_count, 3)
|
||||
self.assertEqual(graph.leaf_nodes()[0].op, "make_cut_rsolid")
|
||||
self.assertEqual(result.get_metadata("graph")["op"], "make_cut_rsolid")
|
||||
|
||||
def test_original_transform_records_graph_automatically(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
moved = scad.translate_shape(box, (1, 0, 0))
|
||||
|
||||
self.assertGreaterEqual(session.graph.node_count, 2)
|
||||
self.assertEqual(session.graph.leaf_nodes()[0].op, "make_translate_rshape")
|
||||
self.assertEqual(moved.get_metadata("graph")["op"], "make_translate_rshape")
|
||||
|
||||
def test_linear_pattern_records_single_user_level_node(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
pattern = scad.linear_pattern_rsolidlist(box, (1, 0, 0), 3, 2.0)
|
||||
|
||||
self.assertGreaterEqual(session.graph.node_count, 4)
|
||||
self.assertEqual(session.graph.leaf_nodes()[0].op, "make_translate_rshape")
|
||||
self.assertTrue(
|
||||
all(
|
||||
s.get_metadata("graph")["op"] == "make_translate_rshape"
|
||||
for s in pattern
|
||||
)
|
||||
)
|
||||
|
||||
def test_radial_pattern_records_single_user_level_node(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
pattern = scad.radial_pattern_rsolidlist(
|
||||
box, (0, 0, 0), (0, 0, 1), 3, 360.0
|
||||
)
|
||||
|
||||
self.assertGreaterEqual(session.graph.node_count, 4)
|
||||
self.assertEqual(session.graph.leaf_nodes()[0].op, "make_translate_rshape")
|
||||
self.assertTrue(
|
||||
all(
|
||||
s.get_metadata("graph")["op"]
|
||||
in {"make_translate_rshape", "make_rotate_rshape"}
|
||||
for s in pattern
|
||||
)
|
||||
)
|
||||
|
||||
def test_profile_creation_and_extrude_record_graph_automatically(self):
|
||||
with GraphSession() as session:
|
||||
profile = scad.make_rectangle_rface(2.0, 1.0)
|
||||
extruded = scad.extrude_rsolid(profile, (0, 0, 1), 2.0)
|
||||
|
||||
self.assertGreaterEqual(session.graph.node_count, 2)
|
||||
self.assertEqual(session.graph.leaf_nodes()[0].op, "make_extrude_rsolid")
|
||||
self.assertEqual(extruded.get_metadata("graph")["op"], "make_extrude_rsolid")
|
||||
|
||||
def test_circle_wire_records_one_user_level_node(self):
|
||||
with GraphSession() as session:
|
||||
wire = scad.make_circle_rwire((0, 0, 0), 2.0)
|
||||
|
||||
self.assertEqual(session.graph.node_count, 2)
|
||||
self.assertEqual(session.graph.leaf_nodes()[0].op, "make_wire_from_edges_rwire")
|
||||
self.assertEqual(wire.get_metadata("graph")["op"], "make_wire_from_edges_rwire")
|
||||
|
||||
def test_subshape_objects_carry_serializable_topo_refs(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
|
||||
face_ref = box.get_faces(0).get_metadata("topo_ref")
|
||||
edge_ref = box.get_edges(0).get_metadata("topo_ref")
|
||||
|
||||
self.assertIsInstance(face_ref, dict)
|
||||
self.assertEqual(face_ref["kind"], "FACE")
|
||||
self.assertEqual(face_ref["node_id"], session.graph.leaf_nodes()[0].node_id)
|
||||
|
||||
self.assertIsInstance(edge_ref, dict)
|
||||
self.assertEqual(edge_ref["kind"], "EDGE")
|
||||
self.assertEqual(edge_ref["node_id"], session.graph.leaf_nodes()[0].node_id)
|
||||
@@ -0,0 +1,782 @@
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import ql
|
||||
|
||||
|
||||
def test_material_validation_and_assignment_are_separate_from_part_creation():
|
||||
body = scad.make_box_rsolid(2.0, 3.0, 1.0)
|
||||
part_signature = inspect.signature(scad.make_part_rpart)
|
||||
|
||||
assert "material" not in part_signature.parameters
|
||||
|
||||
material = scad.make_material_rmaterial(
|
||||
"aluminum_6061",
|
||||
name="Aluminum 6061",
|
||||
density=2.7e-6,
|
||||
density_unit="kg/mm^3",
|
||||
color=(0.7, 0.7, 0.75),
|
||||
)
|
||||
part = scad.make_part_rpart("base_plate", body, name="Base plate")
|
||||
assigned = scad.assign_material_rpart(part, material)
|
||||
|
||||
assert part.material is None
|
||||
assert assigned.material == material
|
||||
assert assigned.body is body
|
||||
|
||||
with pytest.raises(Exception, match="density_unit"):
|
||||
scad.make_material_rmaterial("bad_density", density=1.0)
|
||||
|
||||
with pytest.raises(Exception, match="color"):
|
||||
scad.make_material_rmaterial("bad_color", color=(1.2, 0.0, 0.0))
|
||||
|
||||
|
||||
def test_product_and_constraint_public_apis_do_not_use_bare_star_parameters():
|
||||
public_apis = [
|
||||
scad.make_material_rmaterial,
|
||||
scad.make_placement_rplacement,
|
||||
scad.make_part_rpart,
|
||||
scad.make_assembly_rassembly,
|
||||
scad.add_component_rassembly,
|
||||
scad.make_face_connector_rconnector,
|
||||
scad.make_edge_connector_rconnector,
|
||||
scad.make_vertex_connector_rconnector,
|
||||
scad.make_placement_connector_rconnector,
|
||||
scad.add_connector_rpart,
|
||||
scad.add_connector_rassembly,
|
||||
scad.forward_connector_rassembly,
|
||||
scad.make_connector_ref_rconnectorref,
|
||||
scad.make_scalar_limit_rscalarlimit,
|
||||
scad.ground_component_rassembly,
|
||||
scad.unground_component_rassembly,
|
||||
scad.add_fixed_constraint_rassembly,
|
||||
scad.add_revolute_constraint_rassembly,
|
||||
scad.add_prismatic_constraint_rassembly,
|
||||
scad.add_gear_constraint_rassembly,
|
||||
scad.add_belt_constraint_rassembly,
|
||||
scad.add_rack_pinion_constraint_rassembly,
|
||||
scad.solve_assembly_constraints_rassembly,
|
||||
]
|
||||
|
||||
for api in public_apis:
|
||||
signature = inspect.signature(api)
|
||||
assert all(
|
||||
parameter.kind is not inspect.Parameter.KEYWORD_ONLY
|
||||
for parameter in signature.parameters.values()
|
||||
)
|
||||
|
||||
|
||||
def test_placement_is_canonical_right_handed_frame():
|
||||
placement = scad.make_placement_rplacement(
|
||||
origin=(10.0, 20.0, 30.0),
|
||||
x_axis=(0.0, 1.0, 0.0),
|
||||
y_axis=(-1.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
assert placement.origin == (10.0, 20.0, 30.0)
|
||||
assert placement.z_axis == (0.0, -0.0, 1.0)
|
||||
assert placement.transform_point((1.0, 2.0, 3.0)) == (8.0, 21.0, 33.0)
|
||||
|
||||
with pytest.raises(Exception, match="orthogonal"):
|
||||
scad.make_placement_rplacement(
|
||||
origin=(0.0, 0.0, 0.0),
|
||||
x_axis=(1.0, 0.0, 0.0),
|
||||
y_axis=(1.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="non-zero"):
|
||||
scad.make_placement_rplacement(
|
||||
origin=(0.0, 0.0, 0.0),
|
||||
x_axis=(0.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
|
||||
def test_assembly_components_reuse_part_and_project_to_compound():
|
||||
bolt_body = scad.make_cylinder_rsolid(1.0, 2.0)
|
||||
bolt_part = scad.make_part_rpart("bolt", bolt_body)
|
||||
assembly = scad.make_assembly_rassembly("fixture")
|
||||
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
bolt_part,
|
||||
component_id="bolt_left",
|
||||
placement=scad.make_placement_rplacement(origin=(-5.0, 0.0, 0.0)),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
bolt_part,
|
||||
component_id="bolt_right",
|
||||
placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)),
|
||||
)
|
||||
|
||||
assert assembly.component_ids() == ("bolt_left", "bolt_right")
|
||||
assert assembly.get_component("bolt_left").item is bolt_part
|
||||
|
||||
compound = scad.make_compound_from_assembly_rcompound(assembly)
|
||||
assert isinstance(compound, scad.Compound)
|
||||
assert len(compound.get_solids()) == 2
|
||||
assert math.isclose(compound.get_volume(), 2.0 * bolt_body.get_volume(), rel_tol=1e-7)
|
||||
|
||||
face_centers_x = sorted(round(face.get_center().x, 1) for face in ql.faces().resolve(compound))
|
||||
assert face_centers_x[0] < 0.0
|
||||
assert face_centers_x[-1] > 0.0
|
||||
|
||||
|
||||
def test_nested_assembly_projection_composes_component_placements():
|
||||
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
part = scad.make_part_rpart("cube_part", body)
|
||||
child = scad.make_assembly_rassembly("child_assembly")
|
||||
child = scad.add_component_rassembly(
|
||||
child,
|
||||
part,
|
||||
component_id="cube",
|
||||
placement=scad.make_placement_rplacement(origin=(2.0, 0.0, 0.0)),
|
||||
)
|
||||
root = scad.make_assembly_rassembly("root_assembly")
|
||||
root = scad.add_component_rassembly(
|
||||
root,
|
||||
child,
|
||||
component_id="child",
|
||||
placement=scad.make_placement_rplacement(origin=(10.0, 0.0, 0.0)),
|
||||
)
|
||||
|
||||
compound = scad.make_compound_from_assembly_rcompound(root)
|
||||
face_centers_x = sorted(round(face.get_center().x, 1) for face in ql.faces().resolve(compound))
|
||||
|
||||
assert len(compound.get_solids()) == 1
|
||||
assert face_centers_x[0] >= 11.5
|
||||
assert face_centers_x[-1] <= 12.5
|
||||
|
||||
|
||||
def _part_with_face_connector(part_id):
|
||||
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
part = scad.make_part_rpart(part_id, body)
|
||||
top_face = ql.faces().resolve(body)[-1]
|
||||
connector = scad.make_face_connector_rconnector("axis", top_face)
|
||||
return scad.add_connector_rpart(part, connector)
|
||||
|
||||
|
||||
def _part_with_placement_connector(part_id, connector_origin=(0.0, 0.0, 0.0)):
|
||||
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
part = scad.make_part_rpart(part_id, body)
|
||||
placement = scad.make_placement_rplacement(origin=connector_origin)
|
||||
connector = scad.make_placement_connector_rconnector("axis", placement)
|
||||
return scad.add_connector_rpart(part, connector)
|
||||
|
||||
|
||||
def test_placement_connector_can_drive_fixed_constraints():
|
||||
part = _part_with_placement_connector("placement_constraint_part")
|
||||
assembly = scad.make_assembly_rassembly("placement_constraint_asm")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id="base",
|
||||
placement=scad.make_placement_rplacement(origin=(1.0, 2.0, 3.0)),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id="follower",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "base")
|
||||
assembly = scad.add_fixed_constraint_rassembly(
|
||||
assembly,
|
||||
"fixed",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("follower", "axis"),
|
||||
)
|
||||
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly)
|
||||
|
||||
assert solved.get_component("follower").placement.origin == (1.0, 2.0, 3.0)
|
||||
assert scad.measure_constraint_residual_rconstraintresidual(solved, "fixed").within_tolerance
|
||||
|
||||
|
||||
def test_forwarded_connector_resolves_and_solves_at_parent_level():
|
||||
inner_part = _part_with_placement_connector("forwarded_inner_part", (2.0, 0.0, 0.0))
|
||||
base_part = _part_with_placement_connector("forwarded_base_part")
|
||||
child = scad.make_assembly_rassembly("forwarded_child")
|
||||
child = scad.add_component_rassembly(
|
||||
child,
|
||||
inner_part,
|
||||
component_id="inner",
|
||||
placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)),
|
||||
)
|
||||
child = scad.forward_connector_rassembly(
|
||||
child,
|
||||
connector_id="public_axis",
|
||||
source_component_id="inner",
|
||||
source_connector_id="axis",
|
||||
)
|
||||
|
||||
assert child.connector_ids() == ("public_axis",)
|
||||
assert child.get_connector("public_axis").placement.origin == (7.0, 0.0, 0.0)
|
||||
|
||||
root = scad.make_assembly_rassembly("forwarded_root")
|
||||
root = scad.add_component_rassembly(
|
||||
root,
|
||||
base_part,
|
||||
component_id="base",
|
||||
placement=scad.make_placement_rplacement(origin=(10.0, 0.0, 0.0)),
|
||||
)
|
||||
root = scad.add_component_rassembly(
|
||||
root,
|
||||
child,
|
||||
component_id="child",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
root = scad.ground_component_rassembly(root, "base")
|
||||
root = scad.add_fixed_constraint_rassembly(
|
||||
root,
|
||||
"bind_forwarded_axis",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("child", "public_axis"),
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(root)
|
||||
|
||||
assert solved.get_component("child").placement.origin == (3.0, 0.0, 0.0)
|
||||
assert scad.measure_constraint_residual_rconstraintresidual(
|
||||
solved,
|
||||
"bind_forwarded_axis",
|
||||
).within_tolerance
|
||||
|
||||
|
||||
def test_forwarded_connector_validation_reports_missing_sources():
|
||||
assembly = scad.make_assembly_rassembly("bad_forwarded_connector_asm")
|
||||
|
||||
with pytest.raises(Exception, match="missing component"):
|
||||
scad.forward_connector_rassembly(
|
||||
assembly,
|
||||
connector_id="public_axis",
|
||||
source_component_id="inner",
|
||||
source_connector_id="axis",
|
||||
)
|
||||
|
||||
|
||||
def test_fixed_revolute_and_prismatic_constraints_solve_component_placements():
|
||||
part = _part_with_face_connector("constraint_part")
|
||||
|
||||
fixed_assembly = scad.make_assembly_rassembly("fixed_asm")
|
||||
fixed_assembly = scad.add_component_rassembly(
|
||||
fixed_assembly,
|
||||
part,
|
||||
component_id="base",
|
||||
placement=scad.make_placement_rplacement((1.0, 2.0, 3.0)),
|
||||
)
|
||||
fixed_assembly = scad.add_component_rassembly(
|
||||
fixed_assembly,
|
||||
part,
|
||||
component_id="follower",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
fixed_assembly = scad.ground_component_rassembly(fixed_assembly, "base")
|
||||
fixed_assembly = scad.add_fixed_constraint_rassembly(
|
||||
fixed_assembly,
|
||||
"fixed",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("follower", "axis"),
|
||||
)
|
||||
fixed_solved = scad.solve_assembly_constraints_rassembly(fixed_assembly)
|
||||
assert fixed_solved.get_component("follower").placement.origin == (1.0, 2.0, 3.0)
|
||||
assert scad.measure_constraint_residual_rconstraintresidual(
|
||||
fixed_solved, "fixed"
|
||||
).within_tolerance
|
||||
|
||||
revolute_assembly = scad.make_assembly_rassembly("revolute_asm")
|
||||
revolute_assembly = scad.add_component_rassembly(
|
||||
revolute_assembly,
|
||||
part,
|
||||
component_id="base",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
revolute_assembly = scad.add_component_rassembly(
|
||||
revolute_assembly,
|
||||
part,
|
||||
component_id="arm",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
revolute_assembly = scad.ground_component_rassembly(revolute_assembly, "base")
|
||||
revolute_assembly = scad.add_revolute_constraint_rassembly(
|
||||
revolute_assembly,
|
||||
"hinge",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("arm", "axis"),
|
||||
drive_angle_degrees=90.0,
|
||||
)
|
||||
revolute_solved = scad.solve_assembly_constraints_rassembly(revolute_assembly)
|
||||
arm_placement = revolute_solved.get_component("arm").placement
|
||||
assert math.isclose(arm_placement.origin[0], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(arm_placement.origin[1], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(arm_placement.origin[2], 0.0, abs_tol=1e-12)
|
||||
assert math.isclose(revolute_solved.get_component("arm").placement.x_axis[0], 0.0, abs_tol=1e-10)
|
||||
assert math.isclose(revolute_solved.get_component("arm").placement.x_axis[1], 1.0, abs_tol=1e-10)
|
||||
|
||||
prismatic_assembly = scad.make_assembly_rassembly("prismatic_asm")
|
||||
prismatic_assembly = scad.add_component_rassembly(
|
||||
prismatic_assembly,
|
||||
part,
|
||||
component_id="base",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
prismatic_assembly = scad.add_component_rassembly(
|
||||
prismatic_assembly,
|
||||
part,
|
||||
component_id="slider",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
prismatic_assembly = scad.ground_component_rassembly(prismatic_assembly, "base")
|
||||
prismatic_assembly = scad.add_prismatic_constraint_rassembly(
|
||||
prismatic_assembly,
|
||||
"slide",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("slider", "axis"),
|
||||
drive_distance=5.0,
|
||||
)
|
||||
prismatic_solved = scad.solve_assembly_constraints_rassembly(prismatic_assembly)
|
||||
assert prismatic_solved.get_component("slider").placement.origin == (0.0, 0.0, 5.0)
|
||||
report = scad.inspect_assembly_constraints_rconstraintreport(prismatic_solved)
|
||||
assert report.solved
|
||||
|
||||
|
||||
def test_constraint_validation_rejects_missing_refs_and_limit_violations():
|
||||
part = _part_with_face_connector("limited_part")
|
||||
assembly = scad.make_assembly_rassembly("limited_asm")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id="base",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id="slider",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
connector_a = scad.make_connector_ref_rconnectorref("base", "axis")
|
||||
connector_b = scad.make_connector_ref_rconnectorref("slider", "axis")
|
||||
|
||||
assembly_with_limit = scad.ground_component_rassembly(assembly, "base")
|
||||
assembly_with_limit = scad.add_prismatic_constraint_rassembly(
|
||||
assembly_with_limit, "slide",
|
||||
connector_a,
|
||||
connector_b,
|
||||
drive_distance=10.0,
|
||||
distance_limit=scad.make_scalar_limit_rscalarlimit(0.0, 5.0),
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly_with_limit)
|
||||
assert solved.get_component("slider").placement.origin == (0.0, 0.0, 5.0)
|
||||
|
||||
with pytest.raises(Exception, match="connector"):
|
||||
scad.add_fixed_constraint_rassembly(
|
||||
assembly,
|
||||
"missing",
|
||||
connector_a,
|
||||
scad.make_connector_ref_rconnectorref("slider", "missing_axis"),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="grounded"):
|
||||
ungrounded = scad.make_assembly_rassembly("ungrounded_asm")
|
||||
ungrounded = scad.add_component_rassembly(
|
||||
ungrounded, part, component_id="base", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
ungrounded = scad.add_component_rassembly(
|
||||
ungrounded, part, component_id="slider", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
ungrounded = scad.add_prismatic_constraint_rassembly(
|
||||
ungrounded, "slide_ungrounded",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("slider", "axis"),
|
||||
drive_distance=1.0,
|
||||
)
|
||||
scad.solve_assembly_constraints_rassembly(ungrounded)
|
||||
|
||||
|
||||
def test_assembly_rejects_duplicate_components_raw_solids_and_cycles():
|
||||
body = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
part = scad.make_part_rpart("box_part", body)
|
||||
placement = scad.identity_placement_rplacement()
|
||||
assembly = scad.make_assembly_rassembly("root")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="box", placement=placement
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="duplicate component_id"):
|
||||
scad.add_component_rassembly(
|
||||
assembly, part, component_id="box", placement=placement
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="item"):
|
||||
scad.add_component_rassembly(
|
||||
assembly, body, component_id="raw_solid", placement=placement
|
||||
)
|
||||
|
||||
child = scad.make_assembly_rassembly("child")
|
||||
child = scad.add_component_rassembly(
|
||||
child, assembly, component_id="parent_instance", placement=placement
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="cycle"):
|
||||
scad.add_component_rassembly(
|
||||
assembly, child, component_id="child_instance", placement=placement
|
||||
)
|
||||
|
||||
|
||||
def test_product_graph_model_json_and_replay_roundtrip():
|
||||
with scad.GraphSession() as session:
|
||||
body = scad.make_box_rsolid(2.0, 3.0, 1.0)
|
||||
material = scad.make_material_rmaterial(
|
||||
"steel_8_8",
|
||||
density=7.85e-6,
|
||||
density_unit="kg/mm^3",
|
||||
)
|
||||
part = scad.make_part_rpart("plate", body)
|
||||
part = scad.assign_material_rpart(part, material)
|
||||
assembly = scad.make_assembly_rassembly("fixture")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id="plate_1",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
compound = scad.make_compound_from_assembly_rcompound(assembly)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
|
||||
assert "make_material_rmaterial" in ops
|
||||
assert "make_part_rpart" in ops
|
||||
assert "make_assign_material_rpart" in ops
|
||||
assert "make_assembly_rassembly" in ops
|
||||
assert "make_add_component_rassembly" in ops
|
||||
assert "make_compound_from_assembly_rcompound" in ops
|
||||
assert any(
|
||||
item["entity_type"] == "Part" and item["entity_id"] == "plate"
|
||||
for item in payload["semantic_entity_registry"]
|
||||
)
|
||||
|
||||
replayed = scad.replay_model_json(json.dumps(payload))
|
||||
assert len(replayed) == 1
|
||||
assert isinstance(replayed[0], scad.Compound)
|
||||
assert math.isclose(replayed[0].get_volume(), compound.get_volume(), rel_tol=1e-7)
|
||||
|
||||
|
||||
def test_constraint_graph_model_json_and_replay_roundtrip():
|
||||
with scad.GraphSession() as session:
|
||||
part = _part_with_face_connector("replay_constraint_part")
|
||||
assembly = scad.make_assembly_rassembly("replay_constraint_asm")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id="base",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id="slider",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "base")
|
||||
connector_a = scad.make_connector_ref_rconnectorref("base", "axis")
|
||||
connector_b = scad.make_connector_ref_rconnectorref("slider", "axis")
|
||||
limit = scad.make_scalar_limit_rscalarlimit(0.0, 10.0)
|
||||
assembly = scad.add_prismatic_constraint_rassembly(
|
||||
assembly,
|
||||
"slide",
|
||||
connector_a,
|
||||
connector_b,
|
||||
drive_distance=4.0,
|
||||
distance_limit=limit,
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
assert "make_face_connector_rconnector" in ops
|
||||
assert "make_add_connector_rpart" in ops
|
||||
assert "make_connector_ref_rconnectorref" in ops
|
||||
assert "make_scalar_limit_rscalarlimit" in ops
|
||||
assert "make_ground_component_rassembly" in ops
|
||||
assert "make_prismatic_constraint_rassembly" in ops
|
||||
assert "make_solve_assembly_constraints_rassembly" in ops
|
||||
|
||||
replayed = scad.replay_model_json(json.dumps(payload))
|
||||
assert len(replayed) == 1
|
||||
assert isinstance(replayed[0], scad.Assembly)
|
||||
assert replayed[0].get_component("slider").placement.origin == (0.0, 0.0, 4.0)
|
||||
assert replayed[0].constraints[0].distance_limit.lower_value == 0.0
|
||||
assert solved.get_component("slider").placement.origin == (0.0, 0.0, 4.0)
|
||||
|
||||
|
||||
def test_graph_session_rejects_duplicate_product_ids():
|
||||
with pytest.raises(Exception, match="duplicate part"):
|
||||
with scad.GraphSession():
|
||||
scad.make_part_rpart("same_part", scad.make_box_rsolid(1, 1, 1))
|
||||
scad.make_part_rpart("same_part", scad.make_box_rsolid(1, 1, 1))
|
||||
|
||||
|
||||
def test_limit_aware_prismatic_tree_clamps_scalar_to_bounds():
|
||||
part = _part_with_face_connector("clamp_part")
|
||||
assembly = scad.make_assembly_rassembly("clamp_asm")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="base", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="slider", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "base")
|
||||
assembly = scad.add_prismatic_constraint_rassembly(
|
||||
assembly, "slide",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("slider", "axis"),
|
||||
drive_distance=100.0,
|
||||
distance_limit=scad.make_scalar_limit_rscalarlimit(0.0, 5.0),
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly)
|
||||
assert solved.get_component("slider").placement.origin == (0.0, 0.0, 5.0)
|
||||
residual = scad.measure_constraint_residual_rconstraintresidual(solved, "slide")
|
||||
assert residual.within_tolerance
|
||||
|
||||
|
||||
def test_limit_aware_prismatic_tree_uses_drive_when_within_bounds():
|
||||
part = _part_with_face_connector("inrange_part")
|
||||
assembly = scad.make_assembly_rassembly("inrange_asm")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="base", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="slider", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "base")
|
||||
assembly = scad.add_prismatic_constraint_rassembly(
|
||||
assembly, "slide",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("slider", "axis"),
|
||||
drive_distance=3.0,
|
||||
distance_limit=scad.make_scalar_limit_rscalarlimit(0.0, 10.0),
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly)
|
||||
assert solved.get_component("slider").placement.origin == (0.0, 0.0, 3.0)
|
||||
|
||||
|
||||
def test_limit_aware_revolute_tree_clamps_angle_to_bounds():
|
||||
part = _part_with_face_connector("rev_clamp_part")
|
||||
assembly = scad.make_assembly_rassembly("rev_clamp_asm")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="base", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="arm", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "base")
|
||||
assembly = scad.add_revolute_constraint_rassembly(
|
||||
assembly, "hinge",
|
||||
scad.make_connector_ref_rconnectorref("base", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("arm", "axis"),
|
||||
drive_angle_degrees=999.0,
|
||||
angle_limit=scad.make_scalar_limit_rscalarlimit(0.0, 45.0),
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly)
|
||||
arm_x = solved.get_component("arm").placement.x_axis
|
||||
expected_cos = math.cos(math.radians(45.0))
|
||||
expected_sin = math.sin(math.radians(45.0))
|
||||
assert math.isclose(arm_x[0], expected_cos, abs_tol=1e-10)
|
||||
assert math.isclose(arm_x[1], expected_sin, abs_tol=1e-10)
|
||||
|
||||
|
||||
def test_limit_aware_revolute_loop_finds_optimal_angle():
|
||||
part_a = _part_with_face_connector("loop_a")
|
||||
part_b = _part_with_face_connector("loop_b")
|
||||
assembly = scad.make_assembly_rassembly("rev_loop")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part_a, component_id="link1",
|
||||
placement=scad.make_placement_rplacement(origin=(5.0, 0.0, 0.0)),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part_b, component_id="link2",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "link1")
|
||||
connector_a = scad.make_connector_ref_rconnectorref("link1", "axis")
|
||||
connector_b = scad.make_connector_ref_rconnectorref("link2", "axis")
|
||||
assembly = scad.add_revolute_constraint_rassembly(
|
||||
assembly, "hinge",
|
||||
connector_a, connector_b,
|
||||
angle_limit=scad.make_scalar_limit_rscalarlimit(0.0, 90.0),
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly, strict=False)
|
||||
residual = scad.measure_constraint_residual_rconstraintresidual(solved, "hinge")
|
||||
assert residual.within_tolerance
|
||||
|
||||
|
||||
def _signed_z_angle_degrees(placement):
|
||||
return math.degrees(math.atan2(placement.x_axis[1], placement.x_axis[0]))
|
||||
|
||||
|
||||
def test_gear_and_belt_constraints_couple_revolute_support_joints():
|
||||
part = _part_with_face_connector("coupled_rotor_part")
|
||||
base_ref = scad.make_connector_ref_rconnectorref("base", "axis")
|
||||
gear_a_ref = scad.make_connector_ref_rconnectorref("gear_a", "axis")
|
||||
gear_b_ref = scad.make_connector_ref_rconnectorref("gear_b", "axis")
|
||||
|
||||
gear_assembly = scad.make_assembly_rassembly("gear_coupler_asm")
|
||||
for component_id in ("base", "gear_a", "gear_b"):
|
||||
gear_assembly = scad.add_component_rassembly(
|
||||
gear_assembly,
|
||||
part,
|
||||
component_id=component_id,
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
gear_assembly = scad.ground_component_rassembly(gear_assembly, "base")
|
||||
gear_assembly = scad.add_revolute_constraint_rassembly(
|
||||
gear_assembly, "drive_a", base_ref, gear_a_ref, drive_angle_degrees=90.0,
|
||||
)
|
||||
gear_assembly = scad.add_revolute_constraint_rassembly(
|
||||
gear_assembly, "free_b", base_ref, gear_b_ref,
|
||||
)
|
||||
gear_assembly = scad.add_gear_constraint_rassembly(
|
||||
gear_assembly,
|
||||
"mesh",
|
||||
gear_a_ref,
|
||||
gear_b_ref,
|
||||
pitch_radius_a=1.0,
|
||||
pitch_radius_b=2.0,
|
||||
)
|
||||
gear_solved = scad.solve_assembly_constraints_rassembly(gear_assembly)
|
||||
assert math.isclose(
|
||||
_signed_z_angle_degrees(gear_solved.get_component("gear_b").placement),
|
||||
-45.0,
|
||||
abs_tol=1e-9,
|
||||
)
|
||||
assert scad.measure_constraint_residual_rconstraintresidual(
|
||||
gear_solved, "mesh"
|
||||
).within_tolerance
|
||||
|
||||
belt_assembly = scad.make_assembly_rassembly("belt_coupler_asm")
|
||||
for component_id in ("base", "gear_a", "gear_b"):
|
||||
belt_assembly = scad.add_component_rassembly(
|
||||
belt_assembly,
|
||||
part,
|
||||
component_id=component_id,
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
belt_assembly = scad.ground_component_rassembly(belt_assembly, "base")
|
||||
belt_assembly = scad.add_revolute_constraint_rassembly(
|
||||
belt_assembly, "drive_a", base_ref, gear_a_ref, drive_angle_degrees=90.0,
|
||||
)
|
||||
belt_assembly = scad.add_revolute_constraint_rassembly(
|
||||
belt_assembly, "free_b", base_ref, gear_b_ref,
|
||||
)
|
||||
belt_assembly = scad.add_belt_constraint_rassembly(
|
||||
belt_assembly,
|
||||
"belt",
|
||||
gear_a_ref,
|
||||
gear_b_ref,
|
||||
pulley_radius_a=1.0,
|
||||
pulley_radius_b=2.0,
|
||||
)
|
||||
belt_solved = scad.solve_assembly_constraints_rassembly(belt_assembly)
|
||||
assert math.isclose(
|
||||
_signed_z_angle_degrees(belt_solved.get_component("gear_b").placement),
|
||||
45.0,
|
||||
abs_tol=1e-9,
|
||||
)
|
||||
assert scad.measure_constraint_residual_rconstraintresidual(
|
||||
belt_solved, "belt"
|
||||
).within_tolerance
|
||||
|
||||
|
||||
def test_rack_pinion_constraint_couples_prismatic_and_revolute_support_joints():
|
||||
part = _part_with_face_connector("rack_pinion_part")
|
||||
base_ref = scad.make_connector_ref_rconnectorref("base", "axis")
|
||||
rack_ref = scad.make_connector_ref_rconnectorref("rack", "axis")
|
||||
pinion_ref = scad.make_connector_ref_rconnectorref("pinion", "axis")
|
||||
assembly = scad.make_assembly_rassembly("rack_pinion_asm")
|
||||
for component_id in ("base", "rack", "pinion"):
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly,
|
||||
part,
|
||||
component_id=component_id,
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "base")
|
||||
assembly = scad.add_prismatic_constraint_rassembly(
|
||||
assembly, "rack_slide", base_ref, rack_ref,
|
||||
)
|
||||
assembly = scad.add_revolute_constraint_rassembly(
|
||||
assembly, "pinion_axis", base_ref, pinion_ref, drive_angle_degrees=90.0,
|
||||
)
|
||||
assembly = scad.add_rack_pinion_constraint_rassembly(
|
||||
assembly,
|
||||
"rack_mesh",
|
||||
rack_ref,
|
||||
pinion_ref,
|
||||
pitch_radius=2.0,
|
||||
)
|
||||
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly)
|
||||
|
||||
assert math.isclose(
|
||||
solved.get_component("rack").placement.origin[2],
|
||||
-math.pi,
|
||||
abs_tol=1e-9,
|
||||
)
|
||||
assert scad.measure_constraint_residual_rconstraintresidual(
|
||||
solved, "rack_mesh"
|
||||
).within_tolerance
|
||||
|
||||
|
||||
def test_coupling_constraints_validate_positive_radii():
|
||||
part = _part_with_face_connector("invalid_coupler_part")
|
||||
assembly = scad.make_assembly_rassembly("invalid_coupler_asm")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="a", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="b", placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
ref_a = scad.make_connector_ref_rconnectorref("a", "axis")
|
||||
ref_b = scad.make_connector_ref_rconnectorref("b", "axis")
|
||||
|
||||
with pytest.raises(Exception, match="pitch_radius_a"):
|
||||
scad.add_gear_constraint_rassembly(
|
||||
assembly, "bad_gear", ref_a, ref_b, pitch_radius_a=0.0, pitch_radius_b=1.0,
|
||||
)
|
||||
with pytest.raises(Exception, match="pulley_radius_b"):
|
||||
scad.add_belt_constraint_rassembly(
|
||||
assembly, "bad_belt", ref_a, ref_b, pulley_radius_a=1.0, pulley_radius_b=-1.0,
|
||||
)
|
||||
with pytest.raises(Exception, match="pitch_radius"):
|
||||
scad.add_rack_pinion_constraint_rassembly(
|
||||
assembly, "bad_rack", ref_a, ref_b, pitch_radius=0.0,
|
||||
)
|
||||
|
||||
|
||||
def test_limit_aware_prismatic_loop_finds_optimal_distance():
|
||||
part = _part_with_face_connector("ploop_part")
|
||||
assembly = scad.make_assembly_rassembly("prism_loop")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="fixed_part",
|
||||
placement=scad.make_placement_rplacement(origin=(0.0, 0.0, 2.0)),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly, part, component_id="movable",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.ground_component_rassembly(assembly, "fixed_part")
|
||||
connector_a = scad.make_connector_ref_rconnectorref("fixed_part", "axis")
|
||||
connector_b = scad.make_connector_ref_rconnectorref("movable", "axis")
|
||||
assembly = scad.add_prismatic_constraint_rassembly(
|
||||
assembly, "slide",
|
||||
connector_a, connector_b,
|
||||
distance_limit=scad.make_scalar_limit_rscalarlimit(-5.0, 5.0),
|
||||
)
|
||||
solved = scad.solve_assembly_constraints_rassembly(assembly, strict=False)
|
||||
residual = scad.measure_constraint_residual_rconstraintresidual(solved, "slide")
|
||||
assert residual.within_tolerance
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Tests for stable public API surface.
|
||||
|
||||
The package may add a small number of necessary new APIs for graph/session and
|
||||
serialization, but internal implementation modules should not be advertised from
|
||||
the top-level namespace.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
class TestPublicApiSurface(unittest.TestCase):
|
||||
def test_internal_modules_not_in___all__(self):
|
||||
import simplecadapi as scad
|
||||
|
||||
self.assertNotIn("tracking", scad.__all__)
|
||||
self.assertNotIn("autotag", scad.__all__)
|
||||
self.assertNotIn("topology", scad.__all__)
|
||||
self.assertNotIn("graph", scad.__all__)
|
||||
self.assertNotIn("serializer", scad.__all__)
|
||||
|
||||
def test_only_necessary_new_top_level_apis_are_present(self):
|
||||
code = """
|
||||
import json
|
||||
import simplecadapi as scad
|
||||
print(json.dumps({
|
||||
'has_tracking': hasattr(scad, 'tracking'),
|
||||
'has_autotag': hasattr(scad, 'autotag'),
|
||||
'has_topology': hasattr(scad, 'topology'),
|
||||
'has_graph_module': hasattr(scad, 'graph'),
|
||||
'has_serializer_module': hasattr(scad, 'serializer'),
|
||||
'has_graph_session': hasattr(scad, 'GraphSession'),
|
||||
'has_export_graph_json': hasattr(scad, 'export_graph_json'),
|
||||
'has_import_graph_json': hasattr(scad, 'import_graph_json'),
|
||||
'has_replay_graph': hasattr(scad, 'replay_graph'),
|
||||
'has_apply_tag': hasattr(scad, 'apply_tag'),
|
||||
'has_list_tags': hasattr(scad, 'list_tags'),
|
||||
'has_set_tag': hasattr(scad, 'set_tag'),
|
||||
}))
|
||||
"""
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
payload = json.loads(proc.stdout)
|
||||
|
||||
self.assertFalse(payload["has_tracking"])
|
||||
self.assertFalse(payload["has_autotag"])
|
||||
self.assertFalse(payload["has_topology"])
|
||||
self.assertFalse(payload["has_graph_module"])
|
||||
self.assertFalse(payload["has_serializer_module"])
|
||||
self.assertTrue(payload["has_graph_session"])
|
||||
self.assertTrue(payload["has_export_graph_json"])
|
||||
self.assertTrue(payload["has_import_graph_json"])
|
||||
self.assertTrue(payload["has_replay_graph"])
|
||||
self.assertTrue(payload["has_apply_tag"])
|
||||
self.assertTrue(payload["has_list_tags"])
|
||||
self.assertFalse(payload["has_set_tag"])
|
||||
@@ -0,0 +1,251 @@
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import ql as Q
|
||||
|
||||
|
||||
class TestQLTagPredicates(unittest.TestCase):
|
||||
def test_tag_exact_and_wildcard(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
scad.apply_tag(box, "role.mounting_surface")
|
||||
|
||||
self.assertTrue(Q.tag("role.mounting_surface")(box))
|
||||
self.assertTrue(Q.tag("role.*")(box))
|
||||
self.assertFalse(Q.tag("role.other")(box))
|
||||
|
||||
def test_tag_face_prefix(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
box.auto_tag_faces("box")
|
||||
top_faces = [face for face in box.get_faces() if "face.top" in scad.list_tags(face)]
|
||||
self.assertTrue(top_faces)
|
||||
|
||||
top_face = top_faces[0]
|
||||
self.assertTrue(Q.tag("face.top")(top_face))
|
||||
self.assertTrue(Q.tag("face.*")(top_face))
|
||||
|
||||
|
||||
class TestQLMetadataPredicates(unittest.TestCase):
|
||||
def test_meta_eq_and_compare(self):
|
||||
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
||||
|
||||
self.assertTrue(Q.meta("geo.type", "==", "box")(box))
|
||||
self.assertTrue(Q.meta("geo.size.x", ">", 1.0)(box))
|
||||
|
||||
def test_select_where_order_first(self):
|
||||
c1 = scad.make_cylinder_rsolid(1.0, 1.0)
|
||||
c2 = scad.make_cylinder_rsolid(1.0, 3.0)
|
||||
c3 = scad.make_cylinder_rsolid(1.0, 2.0)
|
||||
|
||||
result = (
|
||||
Q.select([c1, c2, c3]).order_by(Q.value("geo.height"), desc=True).first()
|
||||
)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(result.get_metadata("geo")["height"], 3.0)
|
||||
|
||||
def test_where_and_not(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
cyl = scad.make_cylinder_rsolid(1.0, 1.0)
|
||||
scad.apply_tag(box, "role.mounting_surface")
|
||||
scad.apply_tag(box, "state.debug")
|
||||
|
||||
predicate = Q.and_(
|
||||
Q.meta("geo.type", "==", "box"),
|
||||
Q.tag("role.mounting_surface"),
|
||||
Q.not_(Q.tag("state.*")),
|
||||
)
|
||||
|
||||
result = Q.select([box, cyl]).where(predicate).all()
|
||||
self.assertEqual(result, [])
|
||||
|
||||
predicate = Q.and_(
|
||||
Q.meta("geo.type", "==", "box"),
|
||||
Q.tag("role.mounting_surface"),
|
||||
)
|
||||
|
||||
result = Q.select([box, cyl]).where(predicate).all()
|
||||
self.assertEqual(result, [box])
|
||||
|
||||
def test_first_empty(self):
|
||||
self.assertIsNone(Q.select([]).first())
|
||||
|
||||
|
||||
class TestSerializableGeometrySelectors(unittest.TestCase):
|
||||
def test_generic_property_predicate_matches_geometry_type(self):
|
||||
edge = scad.make_circle_redge((0, 0, 0), 1.0)
|
||||
pred = Q.prop("geom.type", "==", "CIRCLE")
|
||||
self.assertTrue(pred(edge))
|
||||
|
||||
def test_generic_property_key_reads_center_axis(self):
|
||||
face = scad.make_rectangle_rface(2.0, 2.0, center=(0, 0, 3.0))
|
||||
key = Q.key("geom.center.z")
|
||||
self.assertAlmostEqual(key(face), 3.0, places=6)
|
||||
|
||||
def test_custom_property_resolver_extends_ql_without_core_changes(self):
|
||||
obj = object()
|
||||
|
||||
def resolver(target, path):
|
||||
if target is obj and path == "custom.answer":
|
||||
return 42
|
||||
return Q.MISSING
|
||||
|
||||
Q.register_property_resolver("custom.", resolver)
|
||||
try:
|
||||
self.assertTrue(Q.prop("custom.answer", "==", 42)(obj))
|
||||
self.assertEqual(Q.key("custom.answer")(obj), 42)
|
||||
finally:
|
||||
Q.unregister_property_resolver("custom.")
|
||||
|
||||
def test_edge_selector_serializes_and_roundtrips(self):
|
||||
selector = (
|
||||
Q.edges()
|
||||
.where(Q.prop("geom.type", "==", "CIRCLE"))
|
||||
.order_by(Q.key("geom.center.z"))
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
)
|
||||
|
||||
payload = selector.to_dict()
|
||||
restored = Q.selector_from_dict(payload)
|
||||
|
||||
self.assertEqual(payload["target_kind"], "edge")
|
||||
self.assertEqual(payload["limit"], 1)
|
||||
self.assertEqual(payload["cardinality"]["exactly"], 1)
|
||||
self.assertEqual(restored.to_dict(), payload)
|
||||
|
||||
def test_selector_supports_graph_source_multi_key_and_range_cardinality(self):
|
||||
selector = (
|
||||
Q.faces()
|
||||
.from_source("node_a", 0)
|
||||
.order_by(Q.key("geom.center.z"), desc=True)
|
||||
.order_by(Q.key("geom.center.x"))
|
||||
.at_least(1)
|
||||
.at_most(6)
|
||||
)
|
||||
|
||||
payload = selector.to_dict()
|
||||
restored = Q.selector_from_dict(payload)
|
||||
|
||||
self.assertEqual(payload["source_node_id"], "node_a")
|
||||
self.assertEqual(payload["source_output_slot"], 0)
|
||||
self.assertEqual(len(payload["order_keys"]), 2)
|
||||
self.assertEqual(payload["cardinality"]["at_least"], 1)
|
||||
self.assertEqual(payload["cardinality"]["at_most"], 6)
|
||||
self.assertEqual(restored.to_dict(), payload)
|
||||
|
||||
def test_wire_selector_reads_loop_role_property(self):
|
||||
face = scad.make_rectangle_rface(2.0, 2.0)
|
||||
|
||||
wires = (
|
||||
Q.wires()
|
||||
.where(Q.prop("topo.loop_role", "==", "outer"))
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
.resolve(face)
|
||||
)
|
||||
|
||||
self.assertEqual(len(wires), 1)
|
||||
self.assertTrue(Q.prop("topo.loop_role", "==", "outer")(wires[0]))
|
||||
|
||||
def test_face_selector_resolves_geometry_predicates(self):
|
||||
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
||||
selector = (
|
||||
Q.faces()
|
||||
.where(Q.prop("geom.type", "==", "PLANE"))
|
||||
.order_by(Q.key("geom.center.z"), desc=True)
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
)
|
||||
|
||||
faces = selector.resolve(box)
|
||||
self.assertEqual(len(faces), 1)
|
||||
top_face = faces[0]
|
||||
self.assertGreater(top_face.get_normal_at().z, 0.9)
|
||||
|
||||
def test_edge_selector_resolves_circular_bottom_edge(self):
|
||||
with scad.GraphSession():
|
||||
rod = scad.make_cylinder_rsolid(1.0, 5.0, bottom_face_center=(0, 0, 0))
|
||||
selector = (
|
||||
Q.edges()
|
||||
.where(Q.prop("geom.type", "==", "CIRCLE"))
|
||||
.order_by(Q.key("geom.center.z"))
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
)
|
||||
|
||||
edges = selector.resolve(rod)
|
||||
self.assertEqual(len(edges), 1)
|
||||
meta = edges[0].get_metadata("topo_ref")
|
||||
self.assertIsNotNone(meta)
|
||||
self.assertEqual(meta["kind"], "EDGE")
|
||||
|
||||
def test_boundary_traversal_selects_top_face_outer_edges(self):
|
||||
box = scad.make_box_rsolid(2.0, 3.0, 4.0)
|
||||
selector = (
|
||||
Q.faces()
|
||||
.where(Q.prop("geom.normal.z", ">", 0.9))
|
||||
.order_by(Q.key("geom.center.z"), desc=True)
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
.boundary("wire")
|
||||
.where(Q.prop("topo.loop_role", "==", "outer"))
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
.boundary("edge")
|
||||
.exactly(4)
|
||||
)
|
||||
|
||||
edges = selector.resolve(box)
|
||||
self.assertEqual(len(edges), 4)
|
||||
|
||||
payload = selector.to_dict()
|
||||
restored = Q.selector_from_dict(payload)
|
||||
self.assertEqual(len(restored.resolve(box)), 4)
|
||||
self.assertEqual(payload["traversal"]["relation"], "boundary")
|
||||
self.assertEqual(payload["source"]["traversal"]["relation"], "boundary")
|
||||
|
||||
def test_boundary_traversal_selects_cut_top_edges(self):
|
||||
base = scad.make_cylinder_rsolid(1.5, 4.0, bottom_face_center=(0.0, 0.0, -2.0))
|
||||
tool = scad.make_box_rsolid(
|
||||
4.0, 4.0, 4.0, bottom_face_center=(-2.0, -2.0, 0.25)
|
||||
)
|
||||
result = scad.cut_rsolid(base, tool)
|
||||
|
||||
selector = (
|
||||
Q.faces()
|
||||
.where(Q.prop("geom.type", "==", "PLANE"))
|
||||
.where(Q.prop("geom.normal.z", ">", 0.9))
|
||||
.order_by(Q.key("geom.center.z"), desc=True)
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
.boundary("wire")
|
||||
.where(Q.prop("topo.loop_role", "==", "outer"))
|
||||
.take(1)
|
||||
.exactly(1)
|
||||
.boundary("edge")
|
||||
)
|
||||
|
||||
edges = selector.resolve(result)
|
||||
self.assertGreater(len(edges), 0)
|
||||
|
||||
def test_selector_exactly_enforces_cardinality(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
selector = (
|
||||
Q.faces().where(Q.prop("geom.type", "==", "PLANE")).take(2).exactly(1)
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
selector.resolve(box)
|
||||
|
||||
def test_selector_range_cardinality_enforces_bounds(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
Q.faces().take(0).at_least(1).resolve(box)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
Q.faces().at_most(1).resolve(box)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Contract tests for the SimpleCADAPI 2.0 rearchitecture.
|
||||
|
||||
These tests intentionally focus on stable public behavior and on a small number
|
||||
of future-facing expression contracts. The future-facing tests are skipped until
|
||||
the corresponding 2.0 APIs exist.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.graph import GraphSession
|
||||
|
||||
|
||||
REQUIRED_GEOMETRY_TYPES = (
|
||||
"Vertex",
|
||||
"Edge",
|
||||
"Wire",
|
||||
"Face",
|
||||
"Solid",
|
||||
)
|
||||
|
||||
|
||||
REQUIRED_RSTYLE_APIS = (
|
||||
"make_point_rvertex",
|
||||
"make_line_redge",
|
||||
"make_rectangle_rface",
|
||||
"make_box_rsolid",
|
||||
"make_cylinder_rsolid",
|
||||
"extrude_rsolid",
|
||||
"revolve_rsolid",
|
||||
"fillet_rsolid",
|
||||
"chamfer_rsolid",
|
||||
"shell_rsolid",
|
||||
"cut_rsolid",
|
||||
"union_rsolid",
|
||||
"intersect_rsolid",
|
||||
)
|
||||
|
||||
|
||||
class TestRearchitecture20ApiContracts(unittest.TestCase):
|
||||
"""Contracts derived from REQ-API-* in the 2.0 requirements doc."""
|
||||
|
||||
def test_shape_first_geometry_types_remain_public(self):
|
||||
for type_name in REQUIRED_GEOMETRY_TYPES:
|
||||
with self.subTest(type_name=type_name):
|
||||
self.assertTrue(hasattr(scad, type_name))
|
||||
|
||||
def test_rstyle_modeling_api_remains_public(self):
|
||||
for name in REQUIRED_RSTYLE_APIS:
|
||||
with self.subTest(name=name):
|
||||
self.assertTrue(hasattr(scad, name))
|
||||
|
||||
def test_constant_only_modeling_requires_no_expression_wrapper(self):
|
||||
box = scad.make_box_rsolid(4.0, 5.0, 6.0)
|
||||
self.assertIsInstance(box, scad.Solid)
|
||||
self.assertGreater(box.get_volume(), 0.0)
|
||||
|
||||
def test_shape_operations_remain_type_closed_for_core_workflow(self):
|
||||
face = scad.make_rectangle_rface(2.0, 1.0)
|
||||
self.assertIsInstance(face, scad.Face)
|
||||
|
||||
solid = scad.extrude_rsolid(face, (0, 0, 1), 3.0)
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
|
||||
moved = scad.translate_shape(solid, (1.0, 2.0, 3.0))
|
||||
self.assertIsInstance(moved, scad.Solid)
|
||||
|
||||
rotated = scad.rotate_shape(moved, 30.0, (0, 0, 1))
|
||||
self.assertIsInstance(rotated, scad.Solid)
|
||||
|
||||
def test_boolean_pipeline_keeps_returning_solids(self):
|
||||
body = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
tool = scad.make_cylinder_rsolid(0.75, 6.0, bottom_face_center=(0.0, 0.0, -1.0))
|
||||
|
||||
result = scad.cut_rsolid(body, tool)
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
|
||||
|
||||
class TestRearchitecture20ExpressionContracts(unittest.TestCase):
|
||||
"""Future contracts derived from REQ-EXPR-* in the 2.0 requirements doc."""
|
||||
|
||||
def test_explicit_variable_api_exists(self):
|
||||
var = scad.var("r", 10.0)
|
||||
self.assertIsNotNone(var)
|
||||
self.assertIsInstance(var, scad.Var)
|
||||
|
||||
def test_variable_name_is_explicit_and_preserved(self):
|
||||
var = scad.var("radius", 10.0)
|
||||
self.assertEqual(var.name, "radius")
|
||||
|
||||
def test_expression_supports_standard_arithmetic(self):
|
||||
r = scad.var("r", 10.0)
|
||||
expr = ((r + 2) * 3 - 4) / 2
|
||||
self.assertIsInstance(expr, scad.Expr)
|
||||
self.assertAlmostEqual(float(expr), 16.0)
|
||||
|
||||
def test_expression_system_is_unitless_phase_one(self):
|
||||
r = scad.var("r", 10.0)
|
||||
self.assertFalse(hasattr(r, "unit"))
|
||||
|
||||
def test_expression_values_can_flow_into_public_modeling_apis(self):
|
||||
r = scad.var("r", 10.0)
|
||||
face = scad.make_circle_rface((0, 0, 0), r)
|
||||
self.assertIsInstance(face, scad.Face)
|
||||
|
||||
solid = scad.extrude_rsolid(face, (0, 0, 1), r * 2)
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
|
||||
def test_expression_graph_is_public_and_round_trippable(self):
|
||||
graph = scad.ExpressionGraph()
|
||||
r = scad.var("r", 10.0)
|
||||
graph.register(r * 2 + 1)
|
||||
|
||||
payload = graph.to_dict()
|
||||
rebuilt = scad.ExpressionGraph.from_dict(payload)
|
||||
|
||||
self.assertGreaterEqual(rebuilt.node_count, 4)
|
||||
expr_nodes = [node for node in payload["nodes"] if node["kind"] == "expr"]
|
||||
self.assertGreaterEqual(len(expr_nodes), 2)
|
||||
|
||||
def test_graph_session_tracks_expression_graph_separately(self):
|
||||
r = scad.var("r", 5.0)
|
||||
with GraphSession() as session:
|
||||
face = scad.make_circle_rface((0, 0, 0), r)
|
||||
solid = scad.extrude_rsolid(face, (0, 0, 1), r * 2)
|
||||
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
self.assertGreaterEqual(session.graph.node_count, 2)
|
||||
self.assertGreaterEqual(session.expression_graph.node_count, 3)
|
||||
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertIn("distance", leaf.param_exprs)
|
||||
self.assertIn("expr_id", leaf.param_exprs["distance"])
|
||||
|
||||
def test_session_export_includes_expression_graph(self):
|
||||
r = scad.var("r", 3.0)
|
||||
with GraphSession() as session:
|
||||
scad.make_circle_rface((0, 0, 0), r)
|
||||
|
||||
payload = scad.import_session_json(scad.export_session_json(session))
|
||||
self.assertIn("graph", payload)
|
||||
self.assertIn("expression_graph", payload)
|
||||
self.assertGreaterEqual(payload["expression_graph"].node_count, 1)
|
||||
|
||||
|
||||
class TestRearchitecture20GeometryContracts(unittest.TestCase):
|
||||
def test_geometry_layer_remains_shape_first_not_scene_graph_first(self):
|
||||
box = scad.make_box_rsolid(1.0, 2.0, 3.0)
|
||||
self.assertFalse(hasattr(box, "children_nodes"))
|
||||
self.assertFalse(hasattr(box, "scene_transform"))
|
||||
|
||||
def test_sketch_can_exist_without_replacing_shape_hierarchy(self):
|
||||
self.assertTrue(hasattr(scad, "Sketch"))
|
||||
self.assertTrue(hasattr(scad, "Face"))
|
||||
self.assertTrue(hasattr(scad, "Solid"))
|
||||
sketch = scad.Sketch()
|
||||
self.assertIsInstance(sketch, scad.Sketch)
|
||||
|
||||
|
||||
class TestRearchitecture20KernelContracts(unittest.TestCase):
|
||||
def test_geometry_wrappers_accept_raw_occ_shapes_and_expose_wrapped_storage(self):
|
||||
solid = scad.make_box_rsolid(1.0, 2.0, 3.0)
|
||||
clone = scad.Solid(solid.wrapped)
|
||||
self.assertIsInstance(clone, scad.Solid)
|
||||
self.assertTrue(hasattr(clone, "wrapped"))
|
||||
self.assertFalse(hasattr(clone, "cq_solid"))
|
||||
|
||||
def test_public_modeling_path_produces_wrapped_storage_on_shapes(self):
|
||||
face = scad.make_circle_rface((0, 0, 0), 2.0)
|
||||
solid = scad.extrude_rsolid(face, (0, 0, 1), 3.0)
|
||||
self.assertTrue(hasattr(face, "wrapped"))
|
||||
self.assertTrue(hasattr(solid, "wrapped"))
|
||||
|
||||
|
||||
class TestRearchitecture20HistoryContracts(unittest.TestCase):
|
||||
def test_operation_graph_is_still_recorded_for_public_modeling_calls(self):
|
||||
with GraphSession() as session:
|
||||
body = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
tool = scad.make_cylinder_rsolid(
|
||||
0.75, 6.0, bottom_face_center=(0.0, 0.0, -1.0)
|
||||
)
|
||||
result = scad.cut_rsolid(body, tool)
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
self.assertGreaterEqual(session.graph.node_count, 3)
|
||||
allowed_ops = set(payload["canonical_contract"]["core_op_set"])
|
||||
self.assertTrue(all(node.op in allowed_ops for node in session.graph.nodes))
|
||||
|
||||
def test_semantic_delta_exists_alongside_topology_delta(self):
|
||||
delta = scad.SemanticDelta()
|
||||
self.assertTrue(hasattr(delta, "created"))
|
||||
self.assertTrue(hasattr(delta, "modified"))
|
||||
self.assertTrue(hasattr(delta, "deleted"))
|
||||
|
||||
def test_topology_delta_types_still_exposed_in_data_model(self):
|
||||
from simplecadapi.topology import TopoDelta
|
||||
|
||||
delta = TopoDelta()
|
||||
self.assertTrue(hasattr(delta, "preserved"))
|
||||
self.assertTrue(hasattr(delta, "modified"))
|
||||
self.assertTrue(hasattr(delta, "generated"))
|
||||
self.assertTrue(hasattr(delta, "deleted"))
|
||||
|
||||
def test_semantic_ref_exists_beside_topo_ref(self):
|
||||
ref = scad.SemanticRef(
|
||||
graph_id="g0", node_id="n0", entity_type="Sketch", entity_id="sk0"
|
||||
)
|
||||
self.assertEqual(ref.entity_type, "Sketch")
|
||||
|
||||
|
||||
class TestRearchitecture20AssemblyContracts(unittest.TestCase):
|
||||
def test_single_body_part_assembly_mvp_public_surface_is_exposed(self):
|
||||
self.assertTrue(hasattr(scad, "Assembly"))
|
||||
self.assertTrue(hasattr(scad, "Part"))
|
||||
self.assertTrue(hasattr(scad, "Material"))
|
||||
self.assertTrue(hasattr(scad, "Placement"))
|
||||
self.assertTrue(hasattr(scad, "make_assembly_rassembly"))
|
||||
self.assertTrue(hasattr(scad, "make_part_rpart"))
|
||||
self.assertTrue(hasattr(scad, "make_material_rmaterial"))
|
||||
self.assertTrue(hasattr(scad, "make_placement_rplacement"))
|
||||
self.assertTrue(hasattr(scad, "add_component_rassembly"))
|
||||
self.assertTrue(hasattr(scad, "place_component_rassembly"))
|
||||
self.assertTrue(hasattr(scad, "make_compound_from_assembly_rcompound"))
|
||||
self.assertFalse(hasattr(scad, "PartHandle"))
|
||||
self.assertFalse(hasattr(scad, "PointAnchor"))
|
||||
self.assertFalse(hasattr(scad, "AxisAnchor"))
|
||||
self.assertFalse(hasattr(scad, "AssemblyResult"))
|
||||
self.assertFalse(hasattr(scad, "SolveReport"))
|
||||
self.assertFalse(hasattr(scad, "clone_assembly_rassembly"))
|
||||
self.assertFalse(hasattr(scad, "add_part_rassembly"))
|
||||
self.assertFalse(hasattr(scad, "translate_part_rassembly"))
|
||||
self.assertFalse(hasattr(scad, "rotate_part_rassembly"))
|
||||
self.assertFalse(hasattr(scad, "solve_assembly_rresult"))
|
||||
self.assertFalse(hasattr(scad, "constrain_offset_rassembly"))
|
||||
self.assertFalse(hasattr(scad, "constrain_concentric_rassembly"))
|
||||
self.assertFalse(hasattr(scad, "constrain_distance_rassembly"))
|
||||
self.assertFalse(hasattr(scad, "stack_rassembly"))
|
||||
|
||||
def test_model_json_export_has_no_assembly_keyword(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
scad.export_model_json(session, assembly=object())
|
||||
|
||||
|
||||
class TestRearchitecture20IoContracts(unittest.TestCase):
|
||||
def test_session_export_becomes_canonical_json_seed(self):
|
||||
r = scad.var("r", 2.0)
|
||||
with GraphSession() as session:
|
||||
scad.make_circle_rface((0, 0, 0), r)
|
||||
|
||||
payload = scad.import_session_json(scad.export_session_json(session))
|
||||
self.assertGreaterEqual(payload["graph"].node_count, 1)
|
||||
self.assertGreaterEqual(payload["expression_graph"].node_count, 1)
|
||||
|
||||
def test_step_export_still_exists_as_final_geometry_export(self):
|
||||
self.assertTrue(hasattr(scad, "export_step"))
|
||||
self.assertTrue(hasattr(scad, "export_stl"))
|
||||
|
||||
def test_model_json_export_exists_as_canonical_seed(self):
|
||||
r = scad.var("r", 2.0)
|
||||
with GraphSession() as session:
|
||||
scad.make_circle_rface((0, 0, 0), r)
|
||||
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
self.assertIn("graph", payload)
|
||||
self.assertIn("expression_graph", payload)
|
||||
|
||||
def test_model_json_declares_final_state_canonical_contract(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(1.0, 2.0, 3.0)
|
||||
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
|
||||
self.assertIn("canonical_contract", payload)
|
||||
contract = payload["canonical_contract"]
|
||||
self.assertEqual(contract["contract_version"], "2.0")
|
||||
self.assertEqual(contract["graph_roles"]["graph"], "canonical_low_level_graph")
|
||||
self.assertEqual(contract["graph_roles"]["leaf_ids"], "explicit_result_set")
|
||||
self.assertEqual(contract["replay_policy"]["preferred_graph"], "graph")
|
||||
self.assertEqual(contract["replay_policy"]["default_mode"], "strict")
|
||||
self.assertEqual(
|
||||
contract["replay_policy"]["permissive_mode"], "explicit_opt_in"
|
||||
)
|
||||
|
||||
def test_model_json_declares_selection_ref_resolution_order(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
scad.fillet_rsolid(box, [box.get_edges(i) for i in range(2)], 0.2)
|
||||
|
||||
payload = scad.import_model_json(scad.export_model_json(session))
|
||||
selection_schema = payload["canonical_contract"]["selection_ref_schema"]
|
||||
|
||||
self.assertEqual(
|
||||
selection_schema["replay_resolution_order"],
|
||||
[
|
||||
"geo_select_nodes",
|
||||
"selection_query",
|
||||
"explicit_topo_refs",
|
||||
"stable_indices",
|
||||
"selector_hint",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""TDD coverage for the OCP-native primitive solid slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
class TestOcpPrimitiveBuilders(unittest.TestCase):
|
||||
def test_kernel_ocp_builders_module_exposes_core_solid_builders(self):
|
||||
from simplecadapi.kernel.ocp_builders import (
|
||||
make_box_solid,
|
||||
make_cone_solid,
|
||||
make_cylinder_solid,
|
||||
make_sphere_solid,
|
||||
)
|
||||
|
||||
box = scad.Solid(make_box_solid((0.0, 0.0, 0.0), 1.0, 2.0, 3.0))
|
||||
cylinder = scad.Solid(
|
||||
make_cylinder_solid((0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 1.0, 2.0)
|
||||
)
|
||||
cone = scad.Solid(
|
||||
make_cone_solid((0.0, 0.0, 0.0), (0.0, 0.0, 1.0), 1.0, 0.5, 2.0)
|
||||
)
|
||||
sphere = scad.Solid(make_sphere_solid((1.0, 2.0, 3.0), 1.5))
|
||||
|
||||
self.assertAlmostEqual(box.get_volume(), 6.0, places=5)
|
||||
self.assertGreater(cylinder.get_volume(), 0.0)
|
||||
self.assertGreater(cone.get_volume(), 0.0)
|
||||
self.assertGreater(sphere.get_volume(), 0.0)
|
||||
|
||||
def test_make_box_rsolid_uses_ocp_wrapped_storage(self):
|
||||
solid = scad.make_box_rsolid(1.0, 2.0, 3.0)
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
self.assertTrue(hasattr(solid, "wrapped"))
|
||||
self.assertFalse(hasattr(solid, "cq_solid"))
|
||||
self.assertAlmostEqual(solid.get_volume(), 6.0, places=5)
|
||||
|
||||
def test_make_cylinder_rsolid_uses_ocp_wrapped_storage(self):
|
||||
solid = scad.make_cylinder_rsolid(1.0, 2.0)
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
self.assertTrue(hasattr(solid, "wrapped"))
|
||||
self.assertFalse(hasattr(solid, "cq_solid"))
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_make_cone_rsolid_uses_ocp_wrapped_storage(self):
|
||||
solid = scad.make_cone_rsolid(1.0, 2.0, top_radius=0.5)
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
self.assertTrue(hasattr(solid, "wrapped"))
|
||||
self.assertFalse(hasattr(solid, "cq_solid"))
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_make_sphere_rsolid_uses_ocp_wrapped_storage(self):
|
||||
solid = scad.make_sphere_rsolid(1.5, center=(1.0, 2.0, 3.0))
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
self.assertTrue(hasattr(solid, "wrapped"))
|
||||
self.assertFalse(hasattr(solid, "cq_solid"))
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""TDD coverage for the OCP-native transform slice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
class TestOcpTransformBuilders(unittest.TestCase):
|
||||
def test_translate_shape_uses_ocp_wrapped_storage(self):
|
||||
edge = scad.make_line_redge((0.0, 0.0, 0.0), (1.0, 0.0, 0.0))
|
||||
moved = scad.translate_shape(edge, (1.0, 0.0, 0.0))
|
||||
self.assertIsInstance(moved, scad.Edge)
|
||||
self.assertTrue(hasattr(moved, "wrapped"))
|
||||
self.assertFalse(hasattr(moved, "cq_edge"))
|
||||
self.assertAlmostEqual(moved.get_length(), edge.get_length(), places=5)
|
||||
|
||||
def test_rotate_shape_uses_ocp_wrapped_storage(self):
|
||||
edge = scad.make_line_redge((0.0, 0.0, 0.0), (1.0, 0.0, 0.0))
|
||||
rotated = scad.rotate_shape(edge, 90.0, (0.0, 0.0, 1.0))
|
||||
self.assertIsInstance(rotated, scad.Edge)
|
||||
self.assertTrue(hasattr(rotated, "wrapped"))
|
||||
self.assertFalse(hasattr(rotated, "cq_edge"))
|
||||
self.assertAlmostEqual(rotated.get_length(), edge.get_length(), places=5)
|
||||
|
||||
def test_mirror_shape_uses_ocp_wrapped_storage(self):
|
||||
solid = scad.make_box_rsolid(1.0, 2.0, 3.0)
|
||||
mirrored = scad.mirror_shape(solid, (0.0, 0.0, 0.0), (1.0, 0.0, 0.0))
|
||||
self.assertIsInstance(mirrored, scad.Solid)
|
||||
self.assertTrue(hasattr(mirrored, "wrapped"))
|
||||
self.assertFalse(hasattr(mirrored, "cq_solid"))
|
||||
self.assertAlmostEqual(mirrored.get_volume(), solid.get_volume(), places=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Focused 2.0 operation-level tests for remaining core operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.graph import GraphSession
|
||||
|
||||
|
||||
class TestRearchitecture20CoreOps(unittest.TestCase):
|
||||
def test_cylinder_accepts_expression_parameters(self):
|
||||
r = scad.var("r", 1.5)
|
||||
h = scad.var("h", 4.0)
|
||||
solid = scad.make_cylinder_rsolid(r, h, bottom_face_center=(0, 0, 0))
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_revolve_accepts_expression_angle(self):
|
||||
angle = scad.var("angle", 180.0)
|
||||
face = scad.make_rectangle_rface(1.0, 2.0, center=(1.0, 0.0, 0.0))
|
||||
solid = scad.revolve_rsolid(face, axis=(0, 1, 0), angle=angle, origin=(0, 0, 0))
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
|
||||
def test_revolve_produces_topology_delta_at_runtime(self):
|
||||
face = scad.make_rectangle_rface(1.0, 2.0, center=(1.0, 0.0, 0.0))
|
||||
with GraphSession() as session:
|
||||
solid = scad.revolve_rsolid(
|
||||
face, axis=(0, 1, 0), angle=180.0, origin=(0, 0, 0)
|
||||
)
|
||||
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertEqual(leaf.op, "make_revolve_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_revolve_topology_delta_survives_graph_json_roundtrip(self):
|
||||
face = scad.make_rectangle_rface(1.0, 2.0, center=(1.0, 0.0, 0.0))
|
||||
with GraphSession() as session:
|
||||
scad.revolve_rsolid(face, axis=(0, 1, 0), angle=180.0, origin=(0, 0, 0))
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertEqual(leaf.op, "make_revolve_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_loft_produces_topology_delta_at_runtime(self):
|
||||
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0))
|
||||
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0))
|
||||
with GraphSession() as session:
|
||||
solid = scad.loft_rsolid([a, b])
|
||||
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertEqual(leaf.op, "make_loft_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_loft_topology_delta_survives_graph_json_roundtrip(self):
|
||||
a = scad.make_rectangle_rwire(2.0, 2.0, center=(0.0, 0.0, 0.0))
|
||||
b = scad.make_rectangle_rwire(1.0, 1.0, center=(0.0, 0.0, 2.0))
|
||||
with GraphSession() as session:
|
||||
scad.loft_rsolid([a, b])
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertEqual(leaf.op, "make_loft_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_sweep_produces_topology_delta_at_runtime(self):
|
||||
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 0.5)
|
||||
path = scad.make_segment_rwire((0.0, 0.0, 0.0), (0.0, 0.0, 3.0))
|
||||
with GraphSession() as session:
|
||||
solid = scad.sweep_rsolid(profile, path)
|
||||
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertEqual(leaf.op, "make_sweep_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_sweep_topology_delta_survives_graph_json_roundtrip(self):
|
||||
profile = scad.make_circle_rface((0.0, 0.0, 0.0), 0.5)
|
||||
path = scad.make_segment_rwire((0.0, 0.0, 0.0), (0.0, 0.0, 3.0))
|
||||
with GraphSession() as session:
|
||||
scad.sweep_rsolid(profile, path)
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertEqual(leaf.op, "make_sweep_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_fillet_accepts_expression_radius_and_records_param_expr(self):
|
||||
radius = scad.var("fillet_r", 0.2)
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
result = scad.fillet_rsolid(box, [box.get_edges(i) for i in range(4)], radius)
|
||||
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertEqual(leaf.op, "make_fillet_rsolid")
|
||||
self.assertIn("radius", leaf.param_exprs)
|
||||
|
||||
def test_chamfer_accepts_expression_distance_and_records_param_expr(self):
|
||||
distance = scad.var("chamfer_d", 0.2)
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
result = scad.chamfer_rsolid(box, [box.get_edges(i) for i in range(4)], distance)
|
||||
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertEqual(leaf.op, "make_chamfer_rsolid")
|
||||
self.assertIn("distance", leaf.param_exprs)
|
||||
|
||||
def test_shell_accepts_expression_thickness_and_records_param_expr(self):
|
||||
thickness = scad.var("shell_t", 0.2)
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
result = scad.shell_rsolid(box, [box.get_faces(0)], thickness)
|
||||
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertEqual(leaf.op, "make_shell_rsolid")
|
||||
self.assertIn("thickness", leaf.param_exprs)
|
||||
|
||||
def test_shell_produces_topology_delta_at_runtime(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
result = scad.shell_rsolid(box, [box.get_faces(0)], 0.2)
|
||||
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertEqual(leaf.op, "make_shell_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_shell_topology_delta_survives_graph_json_roundtrip(self):
|
||||
with GraphSession() as session:
|
||||
box = scad.make_box_rsolid(4.0, 4.0, 4.0)
|
||||
scad.shell_rsolid(box, [box.get_faces(0)], 0.2)
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
leaf = restored.leaf_nodes()[0]
|
||||
|
||||
self.assertEqual(leaf.op, "make_shell_rsolid")
|
||||
self.assertIsNotNone(leaf.topo_delta)
|
||||
self.assertGreaterEqual(
|
||||
len(leaf.topo_delta.modified)
|
||||
+ len(leaf.topo_delta.generated)
|
||||
+ len(leaf.topo_delta.deleted),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""TDD coverage for remaining primitive/transform expression integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.graph import GraphSession
|
||||
|
||||
|
||||
class TestRemainingPrimitiveExpressionSupport(unittest.TestCase):
|
||||
def test_cone_accepts_expression_parameters(self):
|
||||
r = scad.var("br", 2.0)
|
||||
h = scad.var("h", 5.0)
|
||||
solid = scad.make_cone_rsolid(r, h, top_radius=r / 2)
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
|
||||
def test_sphere_accepts_expression_radius_and_center(self):
|
||||
r = scad.var("sr", 2.0)
|
||||
x = scad.var("sx", 1.0)
|
||||
solid = scad.make_sphere_rsolid(r, center=(x, 0.0, 0.0))
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
|
||||
def test_three_point_arc_accepts_expression_points(self):
|
||||
x = scad.var("ax", 1.0)
|
||||
edge = scad.make_three_point_arc_redge(
|
||||
(0.0, 0.0, 0.0), (x, 1.0, 0.0), (2.0, 0.0, 0.0)
|
||||
)
|
||||
self.assertIsInstance(edge, scad.Edge)
|
||||
|
||||
def test_angle_arc_accepts_expression_radius_and_angles(self):
|
||||
r = scad.var("ar", 2.0)
|
||||
start = scad.var("a0", 0.0)
|
||||
end = scad.var("a1", 1.57)
|
||||
edge = scad.make_angle_arc_redge((0.0, 0.0, 0.0), r, start, end)
|
||||
self.assertIsInstance(edge, scad.Edge)
|
||||
|
||||
def test_spline_accepts_expression_points(self):
|
||||
y = scad.var("sy", 1.0)
|
||||
edge = scad.make_spline_redge(
|
||||
control_points=[
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.6, y, 0.0),
|
||||
(1.4, y, 0.0),
|
||||
(2.0, 0.0, 0.0),
|
||||
]
|
||||
)
|
||||
self.assertIsInstance(edge, scad.Edge)
|
||||
|
||||
|
||||
class TestRemainingTransformExpressionSupport(unittest.TestCase):
|
||||
def test_translate_accepts_expression_vector(self):
|
||||
dx = scad.var("dx", 1.0)
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
moved = scad.translate_shape(box, (dx, 0.0, 0.0))
|
||||
self.assertIsInstance(moved, scad.Solid)
|
||||
|
||||
def test_rotate_accepts_expression_angle_and_axis(self):
|
||||
angle = scad.var("rot", 30.0)
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
moved = scad.rotate_shape(box, angle, (0.0, 0.0, 1.0))
|
||||
self.assertIsInstance(moved, scad.Solid)
|
||||
|
||||
def test_mirror_accepts_expression_plane_origin(self):
|
||||
x = scad.var("mx", 0.0)
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
mirrored = scad.mirror_shape(box, (x, 0.0, 0.0), (1.0, 0.0, 0.0))
|
||||
self.assertIsInstance(mirrored, scad.Solid)
|
||||
|
||||
|
||||
class TestSemanticDeltaProduction(unittest.TestCase):
|
||||
def test_operation_nodes_can_carry_semantic_delta_for_primitives(self):
|
||||
with GraphSession() as session:
|
||||
scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertIsNotNone(leaf.semantic_delta)
|
||||
self.assertGreaterEqual(len(leaf.semantic_delta.created), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""TDD coverage for the remaining structural 2.0 items."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.graph import GraphSession
|
||||
|
||||
|
||||
class TestRemainingStructuralKernel(unittest.TestCase):
|
||||
def test_curve_builders_return_ocp_wrapped_shapes(self):
|
||||
shapes = [
|
||||
scad.make_line_redge((0, 0, 0), (1, 0, 0)),
|
||||
scad.make_circle_redge((0, 0, 0), 1.0),
|
||||
scad.make_three_point_arc_redge((0, 0, 0), (1, 1, 0), (2, 0, 0)),
|
||||
scad.make_angle_arc_redge((0, 0, 0), 1.0, 0.0, 1.57),
|
||||
scad.make_spline_redge(
|
||||
control_points=[(0, 0, 0), (0.6, 1, 0), (1.4, 1, 0), (2, 0, 0)]
|
||||
),
|
||||
scad.make_helix_rwire(1.0, 3.0, 0.5),
|
||||
]
|
||||
for shape in shapes:
|
||||
self.assertTrue(hasattr(shape, "wrapped"))
|
||||
self.assertFalse(hasattr(shape, "cq_edge"))
|
||||
self.assertFalse(hasattr(shape, "cq_wire"))
|
||||
|
||||
def test_loft_sweep_and_helical_sweep_do_not_depend_on_cadquery_feature_builders(
|
||||
self,
|
||||
):
|
||||
rect1 = scad.make_rectangle_rwire(2.0, 2.0, center=(0, 0, 0))
|
||||
rect2 = scad.make_rectangle_rwire(1.0, 1.0, center=(0, 0, 2.0))
|
||||
profile = scad.make_circle_rface((0, 0, 0), 0.5)
|
||||
path = scad.make_segment_rwire((0, 0, 0), (0, 0, 3.0))
|
||||
helix_profile = scad.make_rectangle_rwire(0.4, 0.2)
|
||||
|
||||
loft = scad.loft_rsolid([rect1, rect2])
|
||||
sweep = scad.sweep_rsolid(profile, path)
|
||||
helical = scad.helical_sweep_rsolid(
|
||||
helix_profile, pitch=1.0, height=2.0, radius=1.0
|
||||
)
|
||||
for solid in (loft, sweep, helical):
|
||||
self.assertIsInstance(solid, scad.Solid)
|
||||
self.assertTrue(hasattr(solid, "wrapped"))
|
||||
self.assertFalse(hasattr(solid, "cq_solid"))
|
||||
|
||||
|
||||
class TestRemainingStructuralSemanticAndFrame(unittest.TestCase):
|
||||
def test_semantic_delta_uses_higher_level_entity_types(self):
|
||||
with GraphSession() as session:
|
||||
face = scad.make_circle_rface((0, 0, 0), 2.0)
|
||||
scad.extrude_rsolid(face, (0, 0, 1), 3.0)
|
||||
|
||||
nodes = session.graph.topological_order()
|
||||
created_entity_types = {
|
||||
ref.entity_type
|
||||
for node in nodes
|
||||
if node.semantic_delta is not None
|
||||
for ref in node.semantic_delta.created
|
||||
}
|
||||
self.assertIn("Sketch", created_entity_types)
|
||||
self.assertIn("Feature", created_entity_types)
|
||||
self.assertNotEqual(created_entity_types, {"ShapeOutput"})
|
||||
|
||||
def test_graph_session_owns_independent_frame_graph(self):
|
||||
with GraphSession() as session:
|
||||
with scad.SimpleWorkplane(origin=(1.0, 2.0, 3.0)):
|
||||
scad.make_point_rvertex(1.0, 0.0, 0.0)
|
||||
|
||||
self.assertTrue(hasattr(session, "frame_graph"))
|
||||
self.assertGreaterEqual(session.frame_graph.node_count, 1)
|
||||
|
||||
def test_session_and_model_export_include_frame_graph(self):
|
||||
with GraphSession() as session:
|
||||
with scad.SimpleWorkplane(origin=(1.0, 2.0, 3.0)):
|
||||
scad.make_point_rvertex(1.0, 0.0, 0.0)
|
||||
|
||||
session_payload = scad.import_session_json(scad.export_session_json(session))
|
||||
self.assertIn("frame_graph", session_payload)
|
||||
self.assertGreaterEqual(session_payload["frame_graph"].node_count, 1)
|
||||
|
||||
model_payload = scad.import_model_json(scad.export_model_json(session))
|
||||
self.assertIn("frame_graph", model_payload)
|
||||
self.assertGreaterEqual(model_payload["frame_graph"].node_count, 1)
|
||||
|
||||
raw_model = json.loads(scad.export_model_json(session))
|
||||
self.assertIn("frame_graph", raw_model)
|
||||
self.assertGreaterEqual(len(raw_model["frame_graph"]["nodes"]), 1)
|
||||
|
||||
def test_model_export_includes_semantic_entity_registry_for_sketch_and_feature(
|
||||
self,
|
||||
):
|
||||
with GraphSession() as session:
|
||||
face = scad.make_circle_rface((0.0, 0.0, 0.0), 2.0)
|
||||
scad.extrude_rsolid(face, (0.0, 0.0, 1.0), 3.0)
|
||||
|
||||
raw = json.loads(scad.export_model_json(session))
|
||||
self.assertIn("semantic_entity_registry", raw)
|
||||
entity_types = {item["entity_type"] for item in raw["semantic_entity_registry"]}
|
||||
self.assertIn("Sketch", entity_types)
|
||||
self.assertIn("Feature", entity_types)
|
||||
self.assertNotIn("AssemblyConstraint", entity_types)
|
||||
|
||||
|
||||
class TestHelicalSweepMacroRecording(unittest.TestCase):
|
||||
def test_helical_sweep_expands_to_helix_plus_sweep_nodes(self):
|
||||
with GraphSession() as session:
|
||||
profile = scad.make_rectangle_rwire(0.4, 0.2)
|
||||
result = scad.helical_sweep_rsolid(
|
||||
profile, pitch=1.0, height=2.0, radius=1.0
|
||||
)
|
||||
|
||||
self.assertIsInstance(result, scad.Solid)
|
||||
ops = [node.op for node in session.graph.topological_order()]
|
||||
self.assertIn("make_helix_redge", ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", ops)
|
||||
self.assertIn("make_sweep_rsolid", ops)
|
||||
self.assertNotIn("helical_sweep", ops)
|
||||
|
||||
def test_helical_sweep_roundtrip_replays_as_macro_graph(self):
|
||||
with GraphSession() as session:
|
||||
profile = scad.make_rectangle_rwire(0.4, 0.2)
|
||||
swept = scad.helical_sweep_rsolid(
|
||||
profile, pitch=1.0, height=2.0, radius=1.0
|
||||
)
|
||||
|
||||
restored = scad.import_graph_json(scad.export_graph_json(session.graph))
|
||||
ops = [node.op for node in restored.topological_order()]
|
||||
|
||||
self.assertIn("make_helix_redge", ops)
|
||||
self.assertIn("make_wire_from_edges_rwire", ops)
|
||||
self.assertIn("make_sweep_rsolid", ops)
|
||||
self.assertNotIn("helical_sweep", ops)
|
||||
|
||||
replayed = scad.replay_graph(restored)
|
||||
self.assertEqual(len(replayed), 1)
|
||||
self.assertAlmostEqual(replayed[0].get_volume(), swept.get_volume(), places=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""TDD coverage for remaining Phase 1 expressionized primitive/profile APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.graph import GraphSession
|
||||
|
||||
|
||||
class TestRemainingPrimitiveProfileExpressionSupport(unittest.TestCase):
|
||||
def test_point_accepts_expression_coordinates_and_records_param_exprs(self):
|
||||
x = scad.var("px", 1.0)
|
||||
y = scad.var("py", 2.0)
|
||||
|
||||
with GraphSession() as session:
|
||||
point = scad.make_point_rvertex(x, y, 3.0)
|
||||
|
||||
self.assertIsInstance(point, scad.Vertex)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertIn("x", leaf.param_exprs)
|
||||
self.assertIn("y", leaf.param_exprs)
|
||||
|
||||
def test_line_accepts_expression_points_and_records_param_exprs(self):
|
||||
x = scad.var("lx", 2.0)
|
||||
|
||||
with GraphSession() as session:
|
||||
edge = scad.make_line_redge((0.0, 0.0, 0.0), (x, 0.0, 0.0))
|
||||
|
||||
self.assertIsInstance(edge, scad.Edge)
|
||||
leaf = session.graph.leaf_nodes()[0]
|
||||
self.assertIn("end", leaf.param_exprs)
|
||||
|
||||
def test_rectangle_wire_accepts_expression_dimensions_and_records_param_exprs(self):
|
||||
w = scad.var("rw", 3.0)
|
||||
h = scad.var("rh", 2.0)
|
||||
|
||||
with GraphSession() as session:
|
||||
wire = scad.make_rectangle_rwire(w, h)
|
||||
|
||||
self.assertIsInstance(wire, scad.Wire)
|
||||
ops = session.graph.topological_order()
|
||||
self.assertTrue(any(node.op == "make_line_redge" for node in ops))
|
||||
self.assertTrue(any(node.op == "make_wire_from_edges_rwire" for node in ops))
|
||||
line_nodes = [node for node in ops if node.op == "make_line_redge"]
|
||||
self.assertTrue(
|
||||
any(
|
||||
"start" in node.param_exprs or "end" in node.param_exprs
|
||||
for node in line_nodes
|
||||
)
|
||||
)
|
||||
|
||||
def test_polyline_accepts_expression_points_and_records_param_exprs(self):
|
||||
y = scad.var("ply", 1.0)
|
||||
|
||||
with GraphSession() as session:
|
||||
wire = scad.make_polyline_rwire(
|
||||
[(0.0, 0.0, 0.0), (1.0, y, 0.0), (2.0, 0.0, 0.0)],
|
||||
closed=False,
|
||||
)
|
||||
|
||||
self.assertIsInstance(wire, scad.Wire)
|
||||
ops = session.graph.topological_order()
|
||||
line_nodes = [node for node in ops if node.op == "make_line_redge"]
|
||||
self.assertGreaterEqual(len(line_nodes), 2)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"start" in node.param_exprs or "end" in node.param_exprs
|
||||
for node in line_nodes
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
"""Focused tests for declarative sketch construction and constraints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
class TestSketchApi(unittest.TestCase):
|
||||
def test_sketch_accepts_wire_and_can_build_profile_faces(self):
|
||||
wire = scad.make_rectangle_rwire(2.0, 1.0)
|
||||
sketch = scad.Sketch([wire])
|
||||
|
||||
self.assertEqual(len(sketch.curves()), 1)
|
||||
self.assertEqual(len(sketch.closed_wires()), 1)
|
||||
faces = sketch.to_faces()
|
||||
self.assertEqual(len(faces), 1)
|
||||
self.assertIsInstance(faces[0], scad.Face)
|
||||
|
||||
def _make_constrained_rectangle(self):
|
||||
width = scad.var("sketch_width", 2.0)
|
||||
height = scad.var("sketch_height", 1.0)
|
||||
sketch = scad.make_sketch_rsketch("rect")
|
||||
sketch = scad.add_point_rsketch(sketch, "p0", 0.0, 0.0)
|
||||
sketch = scad.add_point_rsketch(sketch, "p1", 2.0, 0.0)
|
||||
sketch = scad.add_point_rsketch(sketch, "p2", 2.0, 1.0)
|
||||
sketch = scad.add_point_rsketch(sketch, "p3", 0.0, 1.0)
|
||||
sketch = scad.add_line_rsketch(sketch, "bottom", "p0", "p1")
|
||||
sketch = scad.add_line_rsketch(sketch, "right", "p1", "p2")
|
||||
sketch = scad.add_line_rsketch(sketch, "top", "p2", "p3")
|
||||
sketch = scad.add_line_rsketch(sketch, "left", "p3", "p0")
|
||||
sketch = scad.constrain_horizontal_rsketch(sketch, "bottom")
|
||||
sketch = scad.constrain_vertical_rsketch(sketch, "right")
|
||||
sketch = scad.constrain_parallel_rsketch(sketch, "bottom", "top")
|
||||
sketch = scad.constrain_parallel_rsketch(sketch, "left", "right")
|
||||
sketch = scad.constrain_perpendicular_rsketch(sketch, "bottom", "right")
|
||||
sketch = scad.constrain_equal_length_rsketch(sketch, "bottom", "top")
|
||||
sketch = scad.constrain_equal_length_rsketch(sketch, "left", "right")
|
||||
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p1", width)
|
||||
sketch = scad.constrain_distance_rsketch(sketch, "p0", "p3", height)
|
||||
sketch = scad.constrain_fix_rsketch(sketch, "p0")
|
||||
return sketch
|
||||
|
||||
def test_sketch_document_updates_are_functional(self):
|
||||
original = scad.make_sketch_rsketch("functional")
|
||||
with_point = scad.add_point_rsketch(original, "p0", 0.0, 0.0)
|
||||
|
||||
self.assertNotIn("p0", original.entities)
|
||||
self.assertIn("p0", with_point.entities)
|
||||
self.assertIsNot(original, with_point)
|
||||
|
||||
def test_isomorphic_sketch_api_solves_rectangle_and_builds_face(self):
|
||||
sketch = self._make_constrained_rectangle()
|
||||
|
||||
result = scad.inspect_sketch_rsketchresult(
|
||||
sketch, require_fully_constrained=True
|
||||
)
|
||||
self.assertEqual(result.status, "solved")
|
||||
self.assertEqual(result.dof, 0)
|
||||
self.assertAlmostEqual(result.residual_norm, 0.0, places=7)
|
||||
|
||||
face = scad.make_face_from_sketch_rface(sketch, require_fully_constrained=True)
|
||||
self.assertIsInstance(face, scad.Face)
|
||||
self.assertAlmostEqual(face.get_area(), 2.0, places=6)
|
||||
self.assertEqual(face.get_metadata("sketch_solve")["status"], "solved")
|
||||
self.assertEqual(face.get_metadata("source_sketch")["name"], "rect")
|
||||
|
||||
edge_tags = set()
|
||||
for edge in face.get_edges():
|
||||
edge_tags.update(scad.list_tags(edge))
|
||||
self.assertIn("sketch.rect", scad.list_tags(face))
|
||||
self.assertIn("sketch_entity.bottom", edge_tags)
|
||||
self.assertIn("sketch_entity.right", edge_tags)
|
||||
self.assertIn("sketch_entity.top", edge_tags)
|
||||
self.assertIn("sketch_entity.left", edge_tags)
|
||||
|
||||
def test_circle_sketch_constraints_build_circular_face(self):
|
||||
sketch = scad.make_sketch_rsketch("circle")
|
||||
sketch = scad.add_point_rsketch(sketch, "center", 0.0, 0.0)
|
||||
sketch = scad.add_circle_rsketch(sketch, "outer", "center", 1.5)
|
||||
circle = scad.get_sketch_entity_rsketchref(sketch, "outer")
|
||||
sketch = scad.constrain_fix_rsketch(sketch, "center")
|
||||
sketch = scad.constrain_radius_rsketch(sketch, circle, 1.5)
|
||||
|
||||
result = scad.inspect_sketch_rsketchresult(
|
||||
sketch, require_fully_constrained=True
|
||||
)
|
||||
self.assertEqual(result.status, "solved")
|
||||
face = scad.make_face_from_sketch_rface(sketch, require_fully_constrained=True)
|
||||
self.assertAlmostEqual(face.get_area(), 3.141592653589793 * 2.25, places=5)
|
||||
self.assertIn("sketch_entity.outer", scad.list_tags(face.get_edges(0)))
|
||||
|
||||
def test_underconstrained_and_conflicting_sketches_report_diagnostics(self):
|
||||
sketch = scad.make_sketch_rsketch("open")
|
||||
sketch = scad.add_point_rsketch(sketch, "p0", 0.0, 0.0)
|
||||
sketch = scad.add_point_rsketch(sketch, "p1", 1.0, 0.0)
|
||||
sketch = scad.add_line_rsketch(sketch, "line", "p0", "p1")
|
||||
result = scad.inspect_sketch_rsketchresult(sketch, strict=False)
|
||||
self.assertEqual(result.status, "underconstrained")
|
||||
self.assertGreater(result.dof, 0)
|
||||
|
||||
bad = scad.make_sketch_rsketch("bad")
|
||||
bad = scad.add_point_rsketch(bad, "a", 0.0, 0.0)
|
||||
bad = scad.add_point_rsketch(bad, "b", 1.0, 0.0)
|
||||
bad = scad.add_line_rsketch(bad, "line", "a", "b")
|
||||
bad = scad.constrain_distance_rsketch(bad, "a", "b", 1.0)
|
||||
bad = scad.constrain_distance_rsketch(bad, "a", "b", 2.0)
|
||||
bad = scad.constrain_fix_rsketch(bad, "a")
|
||||
bad_result = scad.inspect_sketch_rsketchresult(bad, strict=False)
|
||||
self.assertEqual(bad_result.status, "conflicting")
|
||||
self.assertTrue(any(diag.code == "residual_too_large" for diag in bad_result.diagnostics))
|
||||
|
||||
def test_sketch_refs_are_scoped_to_their_sketch(self):
|
||||
first = scad.make_sketch_rsketch("first")
|
||||
second = scad.make_sketch_rsketch("second")
|
||||
first = scad.add_point_rsketch(first, "p0", 0.0, 0.0)
|
||||
second = scad.add_point_rsketch(second, "p1", 1.0, 0.0)
|
||||
p0 = scad.get_sketch_point_rsketchref(first, "p0")
|
||||
p1 = scad.get_sketch_point_rsketchref(second, "p1")
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
scad.add_line_rsketch(first, "bad", p0, p1)
|
||||
|
||||
def test_graph_replay_preserves_sketch_to_face_result(self):
|
||||
with scad.GraphSession() as session:
|
||||
sketch = self._make_constrained_rectangle()
|
||||
face = scad.make_face_from_sketch_rface(sketch)
|
||||
|
||||
ops = [node.op for node in session.graph.nodes]
|
||||
self.assertIn("make_sketch_rsketch", ops)
|
||||
self.assertIn("make_add_point_rsketch", ops)
|
||||
self.assertIn("make_constrain_parallel_rsketch", ops)
|
||||
self.assertIn("make_face_from_sketch_rface", ops)
|
||||
self.assertNotIn("make_sketch_point_rsketchref", ops)
|
||||
self.assertNotIn("make_solve_sketch_rsketchresult", ops)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
promotion = next(
|
||||
node for node in payload["graph"]["nodes"] if node["op"] == "make_face_from_sketch_rface"
|
||||
)
|
||||
self.assertEqual(promotion["params"]["solve_snapshot"]["status"], "solved")
|
||||
self.assertIn("promotion_map", promotion["params"])
|
||||
|
||||
replayed = scad.replay_model_json(json.dumps(payload))
|
||||
self.assertEqual(len(replayed), 1)
|
||||
self.assertIsInstance(replayed[0], scad.Face)
|
||||
self.assertAlmostEqual(replayed[0].get_area(), face.get_area(), places=6)
|
||||
|
||||
def test_graph_replay_preserves_sketch_bspline_definition(self):
|
||||
with scad.GraphSession() as session:
|
||||
sketch = scad.make_sketch_rsketch("spline")
|
||||
sketch = scad.add_point_rsketch(sketch, "p0", 0.0, 0.0)
|
||||
sketch = scad.add_point_rsketch(sketch, "p1", 4.0, 0.0)
|
||||
sketch = scad.add_bspline_rsketch(
|
||||
sketch,
|
||||
"curve",
|
||||
"p0",
|
||||
"p1",
|
||||
control_points=[
|
||||
[0.0, 0.0],
|
||||
[1.0, 1.5],
|
||||
[3.0, 1.5],
|
||||
[4.0, 0.0],
|
||||
],
|
||||
degree=3,
|
||||
knots=[0.0, 1.0],
|
||||
multiplicities=[4, 4],
|
||||
)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
spline_node = next(
|
||||
node for node in payload["graph"]["nodes"] if node["op"] == "make_add_bspline_rsketch"
|
||||
)
|
||||
self.assertEqual(len(spline_node["params"]["control_points"]), 4)
|
||||
self.assertEqual(spline_node["params"]["knots"], [0.0, 1.0])
|
||||
self.assertEqual(spline_node["params"]["multiplicities"], [4, 4])
|
||||
|
||||
replayed = scad.replay_model_json(json.dumps(payload))
|
||||
self.assertEqual(len(replayed), 1)
|
||||
self.assertIsInstance(replayed[0], scad.Sketch)
|
||||
self.assertEqual(
|
||||
replayed[0].entities["curve"].data["control_points"],
|
||||
sketch.entities["curve"].data["control_points"],
|
||||
)
|
||||
|
||||
def test_strict_replay_requires_sketch_solve_snapshot(self):
|
||||
with scad.GraphSession() as session:
|
||||
sketch = self._make_constrained_rectangle()
|
||||
scad.make_face_from_sketch_rface(sketch)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
promotion = next(
|
||||
node for node in payload["graph"]["nodes"] if node["op"] == "make_face_from_sketch_rface"
|
||||
)
|
||||
del promotion["params"]["solve_snapshot"]
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
scad.replay_model_json(json.dumps(payload))
|
||||
|
||||
replayed = scad.replay_model_json(json.dumps(payload), strict=False)
|
||||
self.assertEqual(len(replayed), 1)
|
||||
self.assertIsInstance(replayed[0], scad.Face)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,205 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1] / "src/simplecadapi/auto_tools/skill_pack.py"
|
||||
)
|
||||
MODULE_SPEC = importlib.util.spec_from_file_location(
|
||||
"simplecadapi_skill_pack",
|
||||
MODULE_PATH,
|
||||
)
|
||||
if MODULE_SPEC is None or MODULE_SPEC.loader is None:
|
||||
raise RuntimeError(f"Unable to load module spec for {MODULE_PATH}")
|
||||
|
||||
skill_pack = importlib.util.module_from_spec(MODULE_SPEC)
|
||||
sys.modules[MODULE_SPEC.name] = skill_pack
|
||||
MODULE_SPEC.loader.exec_module(skill_pack)
|
||||
|
||||
|
||||
class TestSkillPackPathResolution(unittest.TestCase):
|
||||
def test_default_project_root_from_source_checkout(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
project_root = Path(tmp_dir)
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
"[project]\nname = 'simplecadapi'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
package_root = project_root / "src/simplecadapi"
|
||||
module_file = package_root / "auto_tools/skill_pack.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
module_file.write_text("", encoding="utf-8")
|
||||
|
||||
resolved = skill_pack._default_project_root(module_file)
|
||||
|
||||
self.assertEqual(resolved, project_root.resolve())
|
||||
|
||||
def test_default_project_root_from_site_packages_install(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
site_packages = Path(tmp_dir) / ".venv/lib/python3.12/site-packages"
|
||||
module_file = site_packages / "simplecadapi/auto_tools/skill_pack.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
module_file.write_text("", encoding="utf-8")
|
||||
|
||||
resolved = skill_pack._default_project_root(module_file)
|
||||
|
||||
self.assertEqual(resolved, site_packages.resolve())
|
||||
|
||||
def test_default_output_root_from_source_checkout(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
project_root = Path(tmp_dir)
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
"[project]\nname = 'simplecadapi'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project_root / "src/simplecadapi").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
resolved = skill_pack._default_output_root(project_root)
|
||||
|
||||
self.assertEqual(resolved, (project_root / "skills").resolve())
|
||||
|
||||
def test_default_output_root_from_site_packages_install(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
site_packages = tmp_path / ".venv/lib/python3.12/site-packages"
|
||||
site_packages.mkdir(parents=True, exist_ok=True)
|
||||
workspace_root = tmp_path / "workspace"
|
||||
workspace_root.mkdir()
|
||||
|
||||
resolved = skill_pack._default_output_root(
|
||||
site_packages,
|
||||
cwd=workspace_root,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, (workspace_root / "skills").resolve())
|
||||
|
||||
def test_build_from_site_packages_layout_uses_metadata_readme(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
site_packages = tmp_path / ".venv/lib/python3.12/site-packages"
|
||||
docs_api = site_packages / "docs/api"
|
||||
docs_core = site_packages / "docs/core"
|
||||
docs_stdlib = site_packages / "docs/stdlib"
|
||||
docs_api.mkdir(parents=True, exist_ok=True)
|
||||
docs_core.mkdir(parents=True, exist_ok=True)
|
||||
docs_stdlib.mkdir(parents=True, exist_ok=True)
|
||||
(docs_api / "README.md").write_text("# API Docs\n", encoding="utf-8")
|
||||
(docs_core / "README.md").write_text("# Core Docs\n", encoding="utf-8")
|
||||
(docs_stdlib / "README.md").write_text(
|
||||
"# Standard Library Docs\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
dist_info = site_packages / "simplecadapi-2.0.2.dist-info"
|
||||
(dist_info / "licenses").mkdir(parents=True, exist_ok=True)
|
||||
(dist_info / "METADATA").write_text(
|
||||
"Metadata-Version: 2.1\n"
|
||||
"Name: simplecadapi\n"
|
||||
"Version: 2.0.2\n"
|
||||
"Summary: Demo package\n"
|
||||
"\n"
|
||||
"# Demo README\n"
|
||||
"\n"
|
||||
"Installed package readme body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(dist_info / "licenses/LICENSE").write_text(
|
||||
"MIT License\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
output_root = tmp_path / "workspace/skills"
|
||||
packager = skill_pack.SkillPackager(
|
||||
project_root=site_packages,
|
||||
output_root=output_root,
|
||||
skill_name="simplecadapi",
|
||||
license_name="MIT",
|
||||
package_name="simplecadapi",
|
||||
package_version="2.0.2",
|
||||
refresh_docs=True,
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
result = packager.build()
|
||||
|
||||
self.assertTrue((result.skill_root / "SKILL.md").exists())
|
||||
self.assertTrue(
|
||||
(result.skill_root / "references/docs/api/README.md").exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
(result.skill_root / "references/docs/stdlib/README.md").exists()
|
||||
)
|
||||
package_summary = (
|
||||
result.skill_root / "references/SDK_PACKAGE_SUMMARY.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("# SDK Package Summary", package_summary)
|
||||
self.assertIn("Installed package readme body.", package_summary)
|
||||
self.assertIn("references/docs/stdlib/README.md", package_summary)
|
||||
self.assertEqual(
|
||||
(result.skill_root / "references/LICENSE.txt").read_text(
|
||||
encoding="utf-8"
|
||||
),
|
||||
"MIT License\n",
|
||||
)
|
||||
self.assertFalse((result.skill_root / "scripts").exists())
|
||||
|
||||
def test_build_skill_markdown_mentions_graph_and_model_workflow(self):
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
project_root = tmp_path / "project"
|
||||
docs_api = project_root / "docs/api"
|
||||
docs_core = project_root / "docs/core"
|
||||
docs_stdlib = project_root / "docs/stdlib"
|
||||
docs_api.mkdir(parents=True, exist_ok=True)
|
||||
docs_core.mkdir(parents=True, exist_ok=True)
|
||||
docs_stdlib.mkdir(parents=True, exist_ok=True)
|
||||
(docs_api / "README.md").write_text("# API Docs\n", encoding="utf-8")
|
||||
(docs_core / "README.md").write_text("# Core Docs\n", encoding="utf-8")
|
||||
(docs_stdlib / "README.md").write_text(
|
||||
"# Standard Library Docs\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project_root / "README.md").write_text("# Demo\n", encoding="utf-8")
|
||||
(project_root / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
||||
(project_root / "pyproject.toml").write_text(
|
||||
"[project]\nname = 'simplecadapi'\nversion = '2.0.9'\ndescription = 'Demo package'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(project_root / "src/simplecadapi").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
packager = skill_pack.SkillPackager(
|
||||
project_root=project_root,
|
||||
output_root=tmp_path / "skills",
|
||||
skill_name="simplecadapi",
|
||||
license_name="MIT",
|
||||
quiet=True,
|
||||
)
|
||||
|
||||
content = packager._build_skill_markdown()
|
||||
|
||||
self.assertIn("GraphSession", content)
|
||||
self.assertIn("export_model_json", content)
|
||||
self.assertIn("replay_model_json", content)
|
||||
self.assertIn("Use the graph/model JSON workflow", content)
|
||||
self.assertIn("use keyword arguments", content)
|
||||
self.assertIn("do not use positional arguments", content)
|
||||
self.assertIn("Standard Parts Library", content)
|
||||
self.assertIn("references/docs/stdlib/README.md", content)
|
||||
self.assertIn("scad.std.gear", content)
|
||||
self.assertIn("scad.std.bearing", content)
|
||||
self.assertIn("Modeling Mental Model", content)
|
||||
self.assertIn("current modeling workflows", content)
|
||||
self.assertIn("SDK_OVERVIEW.md", content)
|
||||
self.assertIn("SDK_SURFACES.md", content)
|
||||
self.assertIn("MODELING_WORKFLOWS.md", content)
|
||||
self.assertIn("SDK_PACKAGE_SUMMARY.md", content)
|
||||
self.assertNotIn("scripts/", content)
|
||||
self.assertNotIn("v1", content.lower())
|
||||
self.assertNotIn("v2", content.lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Tests for the standard-parts library bearing assemblies."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
class TestStdBearingSurface(unittest.TestCase):
|
||||
def test_preferred_nested_std_export_surface(self):
|
||||
assembly = scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
7,
|
||||
0.05,
|
||||
0.0,
|
||||
"bearing_surface_test",
|
||||
)
|
||||
|
||||
self.assertIsInstance(assembly, scad.Assembly)
|
||||
|
||||
def test_public_bearing_factories_follow_make_rtype_naming(self):
|
||||
factory_names = [
|
||||
name for name in scad.std.bearing.__all__
|
||||
if callable(getattr(scad.std.bearing, name, None))
|
||||
]
|
||||
|
||||
self.assertGreater(len(factory_names), 0)
|
||||
for name in factory_names:
|
||||
self.assertTrue(name.startswith("make_"), name)
|
||||
self.assertIn("_r", name, name)
|
||||
|
||||
def test_ball_bearing_signature_has_no_keyword_only_separator(self):
|
||||
signature = inspect.signature(scad.std.bearing.make_ball_bearing_rassembly)
|
||||
|
||||
self.assertNotIn(
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
{parameter.kind for parameter in signature.parameters.values()},
|
||||
)
|
||||
|
||||
|
||||
class TestBallBearingAssembly(unittest.TestCase):
|
||||
def test_basic_ball_bearing_assembly(self):
|
||||
with scad.GraphSession() as session:
|
||||
bearing = scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
7,
|
||||
0.05,
|
||||
0.05,
|
||||
"bearing_basic_test",
|
||||
30.0,
|
||||
)
|
||||
preview = scad.make_compound_from_assembly_rcompound(bearing)
|
||||
model_json = scad.export_model_json(session)
|
||||
|
||||
meta = bearing.get_metadata("std.bearing.ball_bearing")
|
||||
self.assertEqual(meta["ball_count"], 7)
|
||||
self.assertEqual(meta["outer_component_id"], "outer_ring")
|
||||
self.assertEqual(meta["inner_component_id"], "inner_ring")
|
||||
self.assertEqual(len(meta["ball_component_ids"]), 7)
|
||||
self.assertIn("ball_00", meta["ball_component_ids"])
|
||||
|
||||
self.assertEqual(
|
||||
bearing.component_ids(),
|
||||
(
|
||||
"outer_ring",
|
||||
"inner_ring",
|
||||
"ball_00",
|
||||
"ball_01",
|
||||
"ball_02",
|
||||
"ball_03",
|
||||
"ball_04",
|
||||
"ball_05",
|
||||
"ball_06",
|
||||
),
|
||||
)
|
||||
self.assertEqual(bearing.constraint_ids(), ("inner_outer_revolute",))
|
||||
self.assertEqual(bearing.connector_ids(), ("outer_axis", "inner_axis"))
|
||||
self.assertEqual(bearing.grounded_component_ids, ())
|
||||
constraint = bearing.get_constraint("inner_outer_revolute")
|
||||
self.assertEqual(constraint.constraint_kind, "revolute")
|
||||
self.assertEqual(constraint.connector_a.component_id, "outer_ring")
|
||||
self.assertEqual(constraint.connector_b.component_id, "inner_ring")
|
||||
self.assertEqual(constraint.connector_a.connector_id, "axis")
|
||||
self.assertEqual(constraint.connector_b.connector_id, "axis")
|
||||
self.assertEqual(constraint.drive_angle_degrees, 30.0)
|
||||
|
||||
outer_part = bearing.get_component("outer_ring").item
|
||||
inner_part = bearing.get_component("inner_ring").item
|
||||
ball_part = bearing.get_component("ball_00").item
|
||||
self.assertEqual(outer_part.connector_ids(), ("axis",))
|
||||
self.assertEqual(inner_part.connector_ids(), ("axis",))
|
||||
self.assertEqual(ball_part.connector_ids(), ())
|
||||
self.assertGreater(outer_part.body.get_volume(), 0.0)
|
||||
self.assertGreater(inner_part.body.get_volume(), 0.0)
|
||||
|
||||
outer_ring_meta = outer_part.body.get_metadata("std.bearing.ring")
|
||||
inner_ring_meta = inner_part.body.get_metadata("std.bearing.ring")
|
||||
ball_radius = meta["ball_diameter"] / 2.0
|
||||
self.assertAlmostEqual(
|
||||
outer_ring_meta["inner_radius"],
|
||||
meta["ball_pitch_radius"] + ball_radius * 0.75,
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
inner_ring_meta["outer_radius"],
|
||||
meta["ball_pitch_radius"] - ball_radius * 0.75,
|
||||
)
|
||||
self.assertLess(outer_ring_meta["raceway_mouth_z"], outer_ring_meta["groove_radius"])
|
||||
self.assertLess(inner_ring_meta["raceway_mouth_z"], inner_ring_meta["groove_radius"])
|
||||
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCP.GeomAbs import GeomAbs_Sphere, GeomAbs_Torus
|
||||
|
||||
self.assertTrue(
|
||||
any(
|
||||
BRepAdaptor_Surface(face.wrapped).GetType() == GeomAbs_Torus
|
||||
for face in outer_part.body.get_faces()
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
BRepAdaptor_Surface(face.wrapped).GetType() == GeomAbs_Torus
|
||||
for face in inner_part.body.get_faces()
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
BRepAdaptor_Surface(face.wrapped).GetType() == GeomAbs_Sphere
|
||||
for face in ball_part.body.get_faces()
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(preview.get_solids()), 9)
|
||||
self.assertGreater(preview.get_volume(), 0.0)
|
||||
self.assertTrue(scad.measure_constraint_residual_rconstraintresidual(
|
||||
bearing,
|
||||
"inner_outer_revolute",
|
||||
).within_tolerance)
|
||||
|
||||
payload = json.loads(model_json)
|
||||
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
self.assertIn("make_sphere_rsolid", ops)
|
||||
self.assertEqual(ops.count("make_three_point_arc_redge"), 2)
|
||||
self.assertEqual(ops.count("make_revolve_rsolid"), 2)
|
||||
self.assertIn("make_revolute_constraint_rassembly", ops)
|
||||
self.assertEqual(ops.count("make_forward_connector_rassembly"), 2)
|
||||
self.assertIn("make_compound_from_assembly_rcompound", ops)
|
||||
|
||||
def test_inferred_ball_count_is_recorded(self):
|
||||
bearing = scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
)
|
||||
meta = bearing.get_metadata("std.bearing.ball_bearing")
|
||||
|
||||
self.assertGreaterEqual(meta["ball_count"], 3)
|
||||
self.assertEqual(len(meta["ball_component_ids"]), meta["ball_count"])
|
||||
|
||||
def test_external_constraints_can_bind_to_internal_connectors(self):
|
||||
bearing = scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
7,
|
||||
0.05,
|
||||
0.0,
|
||||
"bearing_bind_test",
|
||||
)
|
||||
|
||||
shaft = scad.make_cylinder_rsolid(
|
||||
radius=3.8,
|
||||
height=14.0,
|
||||
bottom_face_center=(0.0, 0.0, -7.0),
|
||||
axis=(0.0, 0.0, 1.0),
|
||||
)
|
||||
shaft_part = scad.make_part_rpart("shaft", shaft)
|
||||
top_face = max(
|
||||
shaft.get_faces(),
|
||||
key=lambda face: face.get_center().z if face.get_normal_at().z > 0.7 else -999.0,
|
||||
)
|
||||
shaft_axis = scad.make_face_connector_rconnector("axis", top_face)
|
||||
shaft_part = scad.add_connector_rpart(shaft_part, shaft_axis)
|
||||
bearing = scad.add_component_rassembly(
|
||||
bearing,
|
||||
shaft_part,
|
||||
component_id="shaft",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
bearing = scad.add_fixed_constraint_rassembly(
|
||||
bearing,
|
||||
"shaft_to_inner_ring",
|
||||
scad.make_connector_ref_rconnectorref("inner_ring", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("shaft", "axis"),
|
||||
)
|
||||
bearing = scad.ground_component_rassembly(
|
||||
assembly=bearing,
|
||||
component_id="outer_ring",
|
||||
)
|
||||
bearing = scad.solve_assembly_constraints_rassembly(bearing, strict=False)
|
||||
|
||||
self.assertIn("shaft", bearing.component_ids())
|
||||
self.assertTrue(scad.measure_constraint_residual_rconstraintresidual(
|
||||
bearing,
|
||||
"shaft_to_inner_ring",
|
||||
).within_tolerance)
|
||||
|
||||
def test_parent_assembly_can_bind_to_bearing_forwarded_connectors(self):
|
||||
bearing = scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
7,
|
||||
0.05,
|
||||
0.0,
|
||||
"bearing_parent_bind_test",
|
||||
)
|
||||
shaft = scad.make_cylinder_rsolid(
|
||||
radius=3.8,
|
||||
height=14.0,
|
||||
bottom_face_center=(0.0, 0.0, -7.0),
|
||||
axis=(0.0, 0.0, 1.0),
|
||||
)
|
||||
shaft_part = scad.make_part_rpart("parent_bind_shaft", shaft)
|
||||
top_face = max(
|
||||
shaft.get_faces(),
|
||||
key=lambda face: face.get_center().z if face.get_normal_at().z > 0.7 else -999.0,
|
||||
)
|
||||
shaft_axis = scad.make_face_connector_rconnector("axis", top_face)
|
||||
shaft_part = scad.add_connector_rpart(shaft_part, shaft_axis)
|
||||
parent = scad.make_assembly_rassembly("bearing_parent_bind_asm")
|
||||
parent = scad.add_component_rassembly(
|
||||
parent,
|
||||
shaft_part,
|
||||
component_id="shaft",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
parent = scad.add_component_rassembly(
|
||||
parent,
|
||||
bearing,
|
||||
component_id="bearing",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
parent = scad.ground_component_rassembly(parent, "shaft")
|
||||
parent = scad.add_fixed_constraint_rassembly(
|
||||
parent,
|
||||
"shaft_to_bearing_inner_axis",
|
||||
scad.make_connector_ref_rconnectorref("shaft", "axis"),
|
||||
scad.make_connector_ref_rconnectorref("bearing", "inner_axis"),
|
||||
)
|
||||
parent = scad.solve_assembly_constraints_rassembly(parent)
|
||||
|
||||
self.assertTrue(scad.measure_constraint_residual_rconstraintresidual(
|
||||
parent,
|
||||
"shaft_to_bearing_inner_axis",
|
||||
).within_tolerance)
|
||||
|
||||
def test_invalid_params(self):
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.bearing.make_ball_bearing_rassembly(22.0, 8.0, 7.0, 3.5)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.bearing.make_ball_bearing_rassembly(8.0, 22.0, 3.0, 3.5)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.bearing.make_ball_bearing_rassembly(8.0, 22.0, 7.0, 8.0)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
100,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.bearing.make_ball_bearing_rassembly(
|
||||
8.0,
|
||||
22.0,
|
||||
7.0,
|
||||
3.5,
|
||||
7,
|
||||
0.05,
|
||||
2.0,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,665 @@
|
||||
"""Tests for the standard-parts library: gears, ring gears, and racks."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
def _loft_nodes_for(factory):
|
||||
with scad.GraphSession() as session:
|
||||
factory()
|
||||
|
||||
payload = json.loads(scad.export_model_json(session=session))
|
||||
return [node for node in payload["graph"]["nodes"] if node["op"] == "make_loft_rsolid"]
|
||||
|
||||
|
||||
class TestStdGearSurface(unittest.TestCase):
|
||||
def test_preferred_nested_std_export_surface(self):
|
||||
self.assertFalse(hasattr(scad, "std" + "_gear"))
|
||||
solid = scad.std.gear.make_spur_gear_rsolid(
|
||||
n_teeth=8, module=1.0, gear_height=2.0,
|
||||
)
|
||||
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_public_gear_factories_follow_make_rtype_naming(self):
|
||||
factory_names = [
|
||||
name for name in scad.std.gear.__all__
|
||||
if callable(getattr(scad.std.gear, name, None))
|
||||
]
|
||||
|
||||
self.assertGreater(len(factory_names), 0)
|
||||
for name in factory_names:
|
||||
self.assertTrue(name.startswith("make_"), name)
|
||||
self.assertIn("_r", name, name)
|
||||
|
||||
|
||||
class TestSpurGear(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_spur_gear(self):
|
||||
solid = scad.std.gear.make_spur_gear_rsolid(
|
||||
n_teeth=10, module=2.0, pressure_angle=20.0, gear_height=5.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_default_pressure_angle(self):
|
||||
solid = scad.std.gear.make_spur_gear_rsolid(
|
||||
n_teeth=20, module=1.5, gear_height=4.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_more_teeth_larger_volume(self):
|
||||
small = scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, gear_height=5.0)
|
||||
large = scad.std.gear.make_spur_gear_rsolid(n_teeth=24, module=2.0, gear_height=5.0)
|
||||
self.assertGreater(large.get_volume(), small.get_volume())
|
||||
|
||||
def test_height_scales_volume(self):
|
||||
short = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=4.0)
|
||||
tall = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0)
|
||||
self.assertAlmostEqual(tall.get_volume(), 2.0 * short.get_volume(), places=0)
|
||||
|
||||
def test_invalid_params(self):
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_gear_rsolid(n_teeth=2, module=2.0)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=-1.0)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, gear_height=0.0)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_gear_rsolid(n_teeth=10, module=2.0, addendum_factor=0.0)
|
||||
|
||||
def test_tip_radius_bounds(self):
|
||||
n_teeth = 20
|
||||
module = 2.0
|
||||
solid = scad.std.gear.make_spur_gear_rsolid(n_teeth=n_teeth, module=module, gear_height=4.0)
|
||||
expected_tip = module * n_teeth / 2.0 + module
|
||||
max_r = 0.0
|
||||
for face in solid.get_faces():
|
||||
for wire in face.get_wires():
|
||||
for edge in wire.get_edges():
|
||||
for vertex in edge.get_vertices():
|
||||
x, y, _ = vertex.get_coordinates()
|
||||
max_r = max(max_r, math.sqrt(x * x + y * y))
|
||||
self.assertLess(max_r, expected_tip * 1.05)
|
||||
self.assertGreater(max_r, expected_tip * 0.95)
|
||||
|
||||
def test_external_gear_root_transition_is_not_radial_line_patch(self):
|
||||
_face, sketch = scad.std.gear._build_gear_profile_face(
|
||||
n_teeth=18,
|
||||
module=1.5,
|
||||
pressure_angle=math.radians(20.0),
|
||||
return_sketch=True,
|
||||
)
|
||||
self.assertNotIn("line_up_0", sketch.entities)
|
||||
self.assertNotIn("line_down_0", sketch.entities)
|
||||
self.assertEqual(sketch.entities["fillet_left_0"].kind, "bspline")
|
||||
self.assertEqual(sketch.entities["fillet_right_0"].kind, "bspline")
|
||||
|
||||
def test_external_gear_involute_bspline_uses_analytic_endpoints(self):
|
||||
_face, sketch = scad.std.gear._build_gear_profile_face(
|
||||
n_teeth=18,
|
||||
module=1.5,
|
||||
pressure_angle=math.radians(20.0),
|
||||
return_sketch=True,
|
||||
)
|
||||
left = sketch.entities["bspline_left_0"]
|
||||
first_cp = left.data["control_points"][0]
|
||||
last_cp = left.data["control_points"][-1]
|
||||
start = sketch.entities["t0_bs"].data
|
||||
tip = sketch.entities["t0_ts"].data
|
||||
|
||||
self.assertAlmostEqual(first_cp[0], start["x"], places=8)
|
||||
self.assertAlmostEqual(first_cp[1], start["y"], places=8)
|
||||
self.assertAlmostEqual(last_cp[0], tip["x"], places=8)
|
||||
self.assertAlmostEqual(last_cp[1], tip["y"], places=8)
|
||||
|
||||
def test_external_gear_profile_only_fixes_center_point(self):
|
||||
_face, sketch = scad.std.gear._build_gear_profile_face(
|
||||
n_teeth=18,
|
||||
module=1.5,
|
||||
pressure_angle=math.radians(20.0),
|
||||
return_sketch=True,
|
||||
)
|
||||
fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"]
|
||||
|
||||
self.assertEqual(len(fix_constraints), 1)
|
||||
self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center")
|
||||
|
||||
def test_external_gear_backlash_reduces_pitch_tooth_thickness(self):
|
||||
n_teeth = 18
|
||||
module = 1.5
|
||||
pressure_angle = math.radians(20.0)
|
||||
backlash = 0.12
|
||||
no_backlash = scad.std.gear._compute_tooth_geometry(
|
||||
n_teeth, module, pressure_angle, backlash=0.0,
|
||||
)
|
||||
with_backlash = scad.std.gear._compute_tooth_geometry(
|
||||
n_teeth, module, pressure_angle, backlash=backlash,
|
||||
)
|
||||
|
||||
no_backlash_width = no_backlash["right_start"] - no_backlash["left_start"]
|
||||
with_backlash_width = with_backlash["right_start"] - with_backlash["left_start"]
|
||||
|
||||
self.assertLess(with_backlash_width, no_backlash_width)
|
||||
self.assertAlmostEqual(
|
||||
no_backlash_width - with_backlash_width,
|
||||
backlash / no_backlash["pitch_radius"],
|
||||
places=12,
|
||||
)
|
||||
|
||||
def test_external_gear_addendum_and_clearance_factors_control_radii(self):
|
||||
n_teeth = 18
|
||||
module = 1.5
|
||||
pressure_angle = math.radians(20.0)
|
||||
geo = scad.std.gear._compute_tooth_geometry(
|
||||
n_teeth,
|
||||
module,
|
||||
pressure_angle,
|
||||
addendum_factor=0.8,
|
||||
clearance_factor=0.1,
|
||||
)
|
||||
pitch_radius = module * n_teeth / 2.0
|
||||
|
||||
self.assertAlmostEqual(geo["tip_radius"], pitch_radius + 0.8 * module)
|
||||
self.assertAlmostEqual(geo["root_radius"], pitch_radius - 0.9 * module)
|
||||
|
||||
def test_involute_bspline_uses_shared_fit_helper(self):
|
||||
original = scad.std.gear.fit_cubic_bspline_control_points
|
||||
calls = []
|
||||
|
||||
def wrapped(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return original(*args, **kwargs)
|
||||
|
||||
scad.std.gear.fit_cubic_bspline_control_points = wrapped
|
||||
try:
|
||||
scad.std.gear._build_gear_profile_face(
|
||||
n_teeth=12,
|
||||
module=1.5,
|
||||
pressure_angle=math.radians(20.0),
|
||||
)
|
||||
finally:
|
||||
scad.std.gear.fit_cubic_bspline_control_points = original
|
||||
|
||||
self.assertGreater(len(calls), 0)
|
||||
self.assertTrue(all(call[1]["tolerance"] == 1e-4 for call in calls))
|
||||
|
||||
|
||||
class TestHelicalGear(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_helical_gear(self):
|
||||
solid = scad.std.gear.make_helical_gear_rsolid(
|
||||
n_teeth=12, module=2.0, helix_angle=25.0, gear_height=8.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_zero_helix_falls_back_to_spur(self):
|
||||
spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=6.0)
|
||||
helical = scad.std.gear.make_helical_gear_rsolid(
|
||||
n_teeth=12, module=2.0, gear_height=6.0, helix_angle=0.0,
|
||||
)
|
||||
self.assertAlmostEqual(spur.get_volume(), helical.get_volume(), places=0)
|
||||
|
||||
def test_invalid_params(self):
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_helical_gear_rsolid(n_teeth=2, module=2.0)
|
||||
|
||||
def test_helical_gear_uses_small_step_ruled_loft(self):
|
||||
loft_nodes = _loft_nodes_for(
|
||||
lambda: scad.std.gear.make_helical_gear_rsolid(
|
||||
n_teeth=12,
|
||||
module=2.0,
|
||||
helix_angle=25.0,
|
||||
gear_height=8.0,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(loft_nodes), 1)
|
||||
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 7)
|
||||
self.assertTrue(loft_nodes[0]["params"]["ruled"])
|
||||
|
||||
|
||||
class TestHerringboneGear(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_herringbone_gear(self):
|
||||
solid = scad.std.gear.make_herringbone_gear_rsolid(
|
||||
n_teeth=12, module=2.0, helix_angle=25.0, gear_height=10.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_zero_helix_falls_back_to_spur(self):
|
||||
spur = scad.std.gear.make_spur_gear_rsolid(n_teeth=12, module=2.0, gear_height=8.0)
|
||||
herringbone = scad.std.gear.make_herringbone_gear_rsolid(
|
||||
n_teeth=12, module=2.0, gear_height=8.0, helix_angle=0.0,
|
||||
)
|
||||
self.assertAlmostEqual(spur.get_volume(), herringbone.get_volume(), places=0)
|
||||
|
||||
def test_herringbone_gear_uses_small_step_ruled_loft(self):
|
||||
loft_nodes = _loft_nodes_for(
|
||||
lambda: scad.std.gear.make_herringbone_gear_rsolid(
|
||||
n_teeth=12,
|
||||
module=2.0,
|
||||
helix_angle=25.0,
|
||||
gear_height=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(loft_nodes), 1)
|
||||
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9)
|
||||
self.assertTrue(loft_nodes[0]["params"]["ruled"])
|
||||
|
||||
|
||||
class TestSpurRingGear(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_ring_gear(self):
|
||||
solid = scad.std.gear.make_spur_ring_gear_rsolid(
|
||||
n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_ring_volume_less_than_disc(self):
|
||||
n_teeth = 20
|
||||
module = 2.0
|
||||
rim_thickness = 4.0
|
||||
ring = scad.std.gear.make_spur_ring_gear_rsolid(
|
||||
n_teeth=n_teeth, module=module, gear_height=5.0, rim_thickness=rim_thickness,
|
||||
)
|
||||
pitch_radius = module * n_teeth / 2.0
|
||||
outer_r = pitch_radius + 1.25 * module + rim_thickness
|
||||
disc = scad.make_cylinder_rsolid(radius=outer_r, height=5.0)
|
||||
self.assertLess(ring.get_volume(), disc.get_volume())
|
||||
|
||||
def test_ring_profile_uses_internal_tooth_radii(self):
|
||||
n_teeth = 66
|
||||
module = 1.5
|
||||
pressure_angle = math.radians(20.0)
|
||||
face = scad.std.gear._build_ring_gear_face(
|
||||
n_teeth=n_teeth,
|
||||
module=module,
|
||||
pressure_angle=pressure_angle,
|
||||
rim_thickness=4.0,
|
||||
)
|
||||
inner_wire = face.get_inner_wires()[0]
|
||||
|
||||
vertex_radii = [
|
||||
math.hypot(x, y)
|
||||
for edge in inner_wire.get_edges()
|
||||
for vertex in edge.get_vertices()
|
||||
for x, y, _z in [vertex.get_coordinates()]
|
||||
]
|
||||
pitch_radius = module * n_teeth / 2.0
|
||||
base_radius = pitch_radius * math.cos(pressure_angle)
|
||||
self.assertAlmostEqual(min(vertex_radii), pitch_radius - module, places=5)
|
||||
self.assertAlmostEqual(max(vertex_radii), pitch_radius + 1.25 * module, places=5)
|
||||
self.assertGreater(min(vertex_radii), base_radius + 0.5 * module)
|
||||
|
||||
def test_spur_ring_gear_uses_direct_multi_loop_face_not_2d_cut(self):
|
||||
with scad.GraphSession() as session:
|
||||
scad.std.gear.make_spur_ring_gear_rsolid(
|
||||
n_teeth=20, module=2.0, gear_height=5.0, rim_thickness=4.0,
|
||||
)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
self.assertIn("make_face_from_wires_rface", ops)
|
||||
self.assertNotIn("make_2d_cut_rface", ops)
|
||||
|
||||
def test_internal_profile_wire_uses_internal_bspline_flanks(self):
|
||||
_wire, sketch = scad.std.gear._build_internal_gear_profile_wire(
|
||||
n_teeth=66,
|
||||
module=1.5,
|
||||
pressure_angle=math.radians(20.0),
|
||||
return_sketch=True,
|
||||
)
|
||||
left = sketch.entities["bspline_internal_left_0"]
|
||||
right = sketch.entities["bspline_internal_right_0"]
|
||||
self.assertEqual(left.kind, "bspline")
|
||||
self.assertEqual(right.kind, "bspline")
|
||||
self.assertNotIn("bspline_left_0", sketch.entities)
|
||||
self.assertNotIn("bspline_right_0", sketch.entities)
|
||||
|
||||
def test_internal_profile_only_fixes_center_point(self):
|
||||
_wire, sketch = scad.std.gear._build_internal_gear_profile_wire(
|
||||
n_teeth=20,
|
||||
module=1.5,
|
||||
pressure_angle=math.radians(20.0),
|
||||
return_sketch=True,
|
||||
)
|
||||
fix_constraints = [constraint for constraint in sketch.constraints if constraint.kind == "fix"]
|
||||
|
||||
self.assertEqual(len(fix_constraints), 1)
|
||||
self.assertEqual(fix_constraints[0].targets[0]["entity_id"], "center")
|
||||
|
||||
def test_ring_backlash_increases_internal_tooth_space(self):
|
||||
n_teeth = 66
|
||||
module = 1.5
|
||||
pressure_angle = math.radians(20.0)
|
||||
backlash = 0.12
|
||||
no_backlash = scad.std.gear._compute_internal_tooth_geometry(
|
||||
n_teeth, module, pressure_angle, backlash=0.0,
|
||||
)
|
||||
with_backlash = scad.std.gear._compute_internal_tooth_geometry(
|
||||
n_teeth, module, pressure_angle, backlash=backlash,
|
||||
)
|
||||
|
||||
no_backlash_space = no_backlash["tooth_angle"] - (
|
||||
no_backlash["right_root_angle"] - no_backlash["left_root_angle"]
|
||||
)
|
||||
with_backlash_space = with_backlash["tooth_angle"] - (
|
||||
with_backlash["right_root_angle"] - with_backlash["left_root_angle"]
|
||||
)
|
||||
|
||||
self.assertGreater(with_backlash_space, no_backlash_space)
|
||||
self.assertAlmostEqual(
|
||||
with_backlash_space - no_backlash_space,
|
||||
backlash / no_backlash["pitch_radius"],
|
||||
places=12,
|
||||
)
|
||||
|
||||
def test_ring_addendum_and_clearance_factors_control_internal_radii(self):
|
||||
n_teeth = 66
|
||||
module = 1.5
|
||||
pitch, tip, root, outer = scad.std.gear._internal_ring_radii(
|
||||
n_teeth,
|
||||
module,
|
||||
rim_thickness=4.0,
|
||||
addendum_factor=0.8,
|
||||
clearance_factor=0.1,
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(tip, pitch - 0.8 * module)
|
||||
self.assertAlmostEqual(root, pitch + 0.9 * module)
|
||||
self.assertAlmostEqual(outer, root + 4.0)
|
||||
|
||||
def test_invalid_params(self):
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=2, module=2.0)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, rim_thickness=0.0)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_ring_gear_rsolid(n_teeth=10, module=2.0, backlash=-0.1)
|
||||
|
||||
|
||||
class TestHelicalRingGear(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_helical_ring(self):
|
||||
solid = scad.std.gear.make_helical_ring_gear_rsolid(
|
||||
n_teeth=20, module=2.0, helix_angle=20.0, gear_height=8.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_helical_ring_uses_small_step_ruled_inner_loft(self):
|
||||
loft_nodes = _loft_nodes_for(
|
||||
lambda: scad.std.gear.make_helical_ring_gear_rsolid(
|
||||
n_teeth=20,
|
||||
module=2.0,
|
||||
helix_angle=20.0,
|
||||
gear_height=8.0,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(loft_nodes), 1)
|
||||
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 7)
|
||||
self.assertTrue(loft_nodes[0]["params"]["ruled"])
|
||||
|
||||
|
||||
class TestHerringboneRingGear(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_herringbone_ring(self):
|
||||
solid = scad.std.gear.make_herringbone_ring_gear_rsolid(
|
||||
n_teeth=20, module=2.0, helix_angle=20.0, gear_height=10.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_herringbone_ring_uses_small_step_ruled_inner_loft(self):
|
||||
loft_nodes = _loft_nodes_for(
|
||||
lambda: scad.std.gear.make_herringbone_ring_gear_rsolid(
|
||||
n_teeth=20,
|
||||
module=2.0,
|
||||
helix_angle=20.0,
|
||||
gear_height=10.0,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(len(loft_nodes), 1)
|
||||
self.assertEqual(loft_nodes[0]["params"]["profile_count"], 9)
|
||||
self.assertTrue(loft_nodes[0]["params"]["ruled"])
|
||||
|
||||
|
||||
class TestCycloidalDisc(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_cycloidal_disc(self):
|
||||
solid = scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=10,
|
||||
ring_pin_pitch_radius=18.0,
|
||||
roller_radius=1.6,
|
||||
eccentricity=0.8,
|
||||
gear_height=5.7,
|
||||
bore_radius=3.45,
|
||||
output_pin_count=3,
|
||||
output_pin_pitch_radius=6.4,
|
||||
output_pin_clearance_radius=2.05,
|
||||
output_pin_phase=60.0,
|
||||
)
|
||||
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
meta = solid.get_metadata("std.gear.cycloidal_disc")
|
||||
self.assertEqual(meta["pin_count"], 11)
|
||||
self.assertEqual(meta["n_lobes"], 10)
|
||||
self.assertEqual(meta["segment_count"], 10)
|
||||
self.assertLess(meta["radius_min"], meta["radius_max"])
|
||||
|
||||
def test_cycloidal_disc_uses_one_bspline_per_lobe(self):
|
||||
with scad.GraphSession() as session:
|
||||
scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=8,
|
||||
ring_pin_pitch_radius=15.0,
|
||||
roller_radius=1.2,
|
||||
eccentricity=0.7,
|
||||
gear_height=4.0,
|
||||
)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
self.assertEqual(ops.count("make_spline_redge"), 8)
|
||||
self.assertEqual(ops.count("make_line_redge"), 0)
|
||||
|
||||
def test_cycloidal_disc_holes_reduce_volume(self):
|
||||
plain = scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=10,
|
||||
ring_pin_pitch_radius=18.0,
|
||||
roller_radius=1.6,
|
||||
eccentricity=0.8,
|
||||
gear_height=5.7,
|
||||
)
|
||||
bored = scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=10,
|
||||
ring_pin_pitch_radius=18.0,
|
||||
roller_radius=1.6,
|
||||
eccentricity=0.8,
|
||||
gear_height=5.7,
|
||||
bore_radius=3.45,
|
||||
output_pin_count=3,
|
||||
output_pin_pitch_radius=6.4,
|
||||
output_pin_clearance_radius=2.05,
|
||||
)
|
||||
|
||||
self.assertLess(bored.get_volume(), plain.get_volume())
|
||||
|
||||
def test_twin_disc_workflow_uses_half_lobe_phase(self):
|
||||
n_lobes = 4
|
||||
output_pin_phase = 60.0
|
||||
half_lobe_phase = 180.0 / n_lobes
|
||||
|
||||
with scad.GraphSession() as session:
|
||||
scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=n_lobes,
|
||||
ring_pin_pitch_radius=10.0,
|
||||
roller_radius=0.9,
|
||||
eccentricity=0.45,
|
||||
gear_height=2.0,
|
||||
bore_radius=1.5,
|
||||
output_pin_count=3,
|
||||
output_pin_pitch_radius=3.0,
|
||||
output_pin_clearance_radius=0.9,
|
||||
output_pin_phase=output_pin_phase,
|
||||
)
|
||||
upper = scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=n_lobes,
|
||||
ring_pin_pitch_radius=10.0,
|
||||
roller_radius=0.9,
|
||||
eccentricity=0.45,
|
||||
gear_height=2.0,
|
||||
bore_radius=1.5,
|
||||
output_pin_count=3,
|
||||
output_pin_pitch_radius=3.0,
|
||||
output_pin_clearance_radius=0.9,
|
||||
output_pin_phase=output_pin_phase - half_lobe_phase,
|
||||
)
|
||||
scad.rotate_shape(
|
||||
upper,
|
||||
half_lobe_phase,
|
||||
axis=(0.0, 0.0, 1.0),
|
||||
origin=(0.0, 0.0, 0.0),
|
||||
)
|
||||
|
||||
upper_meta = upper.get_metadata("std.gear.cycloidal_disc")
|
||||
self.assertEqual(
|
||||
upper_meta["output_pin_phase"],
|
||||
output_pin_phase - half_lobe_phase,
|
||||
)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
rotate_angles = [
|
||||
node["params"]["angle"]
|
||||
for node in payload["graph"]["nodes"]
|
||||
if node["op"] == "make_rotate_rshape"
|
||||
]
|
||||
self.assertEqual(ops.count("make_spline_redge"), n_lobes * 2)
|
||||
self.assertEqual(rotate_angles, [half_lobe_phase])
|
||||
|
||||
def test_invalid_params(self):
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=1,
|
||||
ring_pin_pitch_radius=18.0,
|
||||
roller_radius=1.6,
|
||||
eccentricity=0.8,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_cycloidal_disc_rsolid(
|
||||
n_lobes=10,
|
||||
ring_pin_pitch_radius=18.0,
|
||||
roller_radius=1.6,
|
||||
eccentricity=0.8,
|
||||
output_pin_count=3,
|
||||
)
|
||||
|
||||
|
||||
class TestSpurRack(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_rack(self):
|
||||
solid = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=8, rack_height=5.0)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
def test_more_teeth_larger_volume(self):
|
||||
short = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=5, rack_height=5.0)
|
||||
long = scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=10, rack_height=5.0)
|
||||
self.assertGreater(long.get_volume(), short.get_volume())
|
||||
|
||||
def test_invalid_params(self):
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_rack_rsolid(module=-1.0)
|
||||
with self.assertRaises(Exception):
|
||||
scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=0)
|
||||
|
||||
def test_rack_profile_has_no_fix_constraints(self):
|
||||
with scad.GraphSession() as session:
|
||||
scad.std.gear.make_spur_rack_rsolid(module=2.0, n_teeth=5, rack_height=5.0)
|
||||
|
||||
payload = json.loads(scad.export_model_json(session))
|
||||
ops = [node["op"] for node in payload["graph"]["nodes"]]
|
||||
self.assertNotIn("make_constrain_fix_rsketch", ops)
|
||||
|
||||
|
||||
class TestHelicalRack(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_helical_rack(self):
|
||||
solid = scad.std.gear.make_helical_rack_rsolid(
|
||||
module=2.0, n_teeth=8, helix_angle=25.0, rack_height=8.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
|
||||
class TestHerringboneRack(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_basic_herringbone_rack(self):
|
||||
solid = scad.std.gear.make_herringbone_rack_rsolid(
|
||||
module=2.0, n_teeth=8, helix_angle=30.0, rack_height=10.0,
|
||||
)
|
||||
self.assertGreater(solid.get_volume(), 0.0)
|
||||
|
||||
|
||||
class Test2DFaceBoolean(unittest.TestCase):
|
||||
def setUp(self):
|
||||
scad.GraphSession()
|
||||
|
||||
def test_make_2d_cut_rface_creates_hole(self):
|
||||
outer = scad.make_circle_rface(center=(0, 0, 0), radius=10.0)
|
||||
inner = scad.make_circle_rface(center=(0, 0, 0), radius=4.0)
|
||||
ring = scad.make_2d_cut_rface(outer, inner)
|
||||
self.assertAlmostEqual(
|
||||
ring.get_area(), math.pi * (100 - 16), places=1,
|
||||
)
|
||||
self.assertEqual(len(ring.get_inner_wires()), 1)
|
||||
|
||||
def test_make_face_from_wires_rface_creates_hole(self):
|
||||
outer = scad.make_circle_rwire(center=(0, 0, 0), radius=10.0)
|
||||
inner = scad.make_circle_rwire(center=(0, 0, 0), radius=4.0)
|
||||
ring = scad.make_face_from_wires_rface(outer, [inner])
|
||||
self.assertAlmostEqual(
|
||||
ring.get_area(), math.pi * (100 - 16), places=1,
|
||||
)
|
||||
self.assertEqual(len(ring.get_inner_wires()), 1)
|
||||
|
||||
def test_make_2d_union_rface(self):
|
||||
a = scad.make_circle_rface(center=(0, 0, 0), radius=5.0)
|
||||
b = scad.make_circle_rface(center=(3, 0, 0), radius=5.0)
|
||||
merged = scad.make_2d_union_rface(a, b)
|
||||
self.assertGreater(merged.get_area(), math.pi * 25)
|
||||
|
||||
def test_make_2d_intersect_rface(self):
|
||||
a = scad.make_circle_rface(center=(0, 0, 0), radius=5.0)
|
||||
b = scad.make_circle_rface(center=(3, 0, 0), radius=5.0)
|
||||
overlap = scad.make_2d_intersect_rface(a, b)
|
||||
self.assertGreater(overlap.get_area(), 0.0)
|
||||
self.assertLess(overlap.get_area(), math.pi * 25)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi import tagging
|
||||
|
||||
|
||||
class TestTaggingRefactor(unittest.TestCase):
|
||||
def test_tag_validation(self):
|
||||
self.assertTrue(tagging.is_normalized_tag("geom.primitive.box"))
|
||||
self.assertTrue(tagging.is_normalized_tag("face.top"))
|
||||
self.assertFalse(tagging.is_normalized_tag("Face.Top"))
|
||||
self.assertFalse(tagging.is_normalized_tag("size: 2x3x4"))
|
||||
|
||||
def test_tag_policy_propagation(self):
|
||||
policy = tagging.DEFAULT_TAG_POLICY
|
||||
self.assertTrue(policy.should_propagate("role.mounting_surface"))
|
||||
self.assertTrue(policy.should_propagate("anchor.datum.primary"))
|
||||
self.assertTrue(policy.should_propagate("group.fasteners"))
|
||||
self.assertFalse(policy.should_propagate("feature.extrude.start_face"))
|
||||
self.assertFalse(policy.should_propagate("state.debug"))
|
||||
self.assertFalse(policy.should_propagate("face.top"))
|
||||
self.assertFalse(policy.should_propagate("legacy.top"))
|
||||
|
||||
def test_apply_tag_propagates_role(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
scad.apply_tag(box, "role.mounting_surface")
|
||||
|
||||
faces = box.get_faces()
|
||||
self.assertTrue(any("role.mounting_surface" in scad.list_tags(face) for face in faces))
|
||||
|
||||
edges = box.get_edges()
|
||||
self.assertTrue(any("role.mounting_surface" in scad.list_tags(edge) for edge in edges))
|
||||
|
||||
def test_tagging_public_surface_is_functional_and_sorted(self):
|
||||
vertex = scad.make_point_rvertex(0.0, 0.0, 0.0)
|
||||
|
||||
scad.apply_tag(vertex, "role.zeta")
|
||||
scad.apply_tag(vertex, "role.alpha")
|
||||
|
||||
self.assertEqual(scad.list_tags(vertex), ["role.alpha", "role.zeta"])
|
||||
for member_name in ("add_tag", "apply_tag", "get_tags", "has_tag", "remove_tag"):
|
||||
self.assertFalse(hasattr(vertex, member_name))
|
||||
self.assertFalse(hasattr(scad, "set_tag"))
|
||||
|
||||
def test_anchor_resolution_candidate_priority(self):
|
||||
candidates = tagging.resolve_anchor_tag_candidates("mounting_surface")
|
||||
|
||||
self.assertEqual(candidates[0], "role.mounting_surface")
|
||||
self.assertEqual(candidates[1], "anchor.mounting_surface")
|
||||
self.assertIn("face.mounting_surface", candidates)
|
||||
self.assertEqual(candidates[-1], "mounting_surface")
|
||||
|
||||
def test_new_primitive_tags_are_normalized_and_geo_metadata_carries_values(self):
|
||||
box = scad.make_box_rsolid(1.0, 2.0, 3.0)
|
||||
|
||||
box_tags = scad.list_tags(box)
|
||||
self.assertEqual(box_tags, sorted(box_tags))
|
||||
self.assertTrue(all(tagging.is_normalized_tag(tag) for tag in box_tags))
|
||||
self.assertFalse(any(tag.isdigit() for tag in box_tags))
|
||||
self.assertFalse(any(":" in tag or " " in tag for tag in box_tags))
|
||||
self.assertEqual(box.get_metadata("geo")["size"], {"x": 1.0, "y": 2.0, "z": 3.0})
|
||||
|
||||
def test_wire_edge_indices_live_in_geo_metadata_not_tags(self):
|
||||
wire = scad.make_rectangle_rwire(1.0, 1.0)
|
||||
edges = wire.get_edges()
|
||||
|
||||
self.assertTrue(edges)
|
||||
self.assertFalse(any(tag.isdigit() for edge in edges for tag in scad.list_tags(edge)))
|
||||
self.assertTrue(all(edge.get_metadata("geo")["edge_index"] >= 0 for edge in edges))
|
||||
|
||||
def test_anchor_resolution_prefers_role_over_anchor_and_topology_tags(self):
|
||||
candidates = tagging.resolve_anchor_tag_candidates("datum")
|
||||
|
||||
self.assertLess(candidates.index("role.datum"), candidates.index("anchor.datum"))
|
||||
self.assertLess(candidates.index("anchor.datum"), candidates.index("face.datum"))
|
||||
|
||||
|
||||
class TestAutoTagFacesNamespaces(unittest.TestCase):
|
||||
def test_box_faces_have_new_tags(self):
|
||||
box = scad.make_box_rsolid(1.0, 1.0, 1.0)
|
||||
box.auto_tag_faces("box")
|
||||
faces = box.get_faces()
|
||||
self.assertTrue(any("face.top" in scad.list_tags(face) for face in faces))
|
||||
self.assertTrue(any("face.bottom" in scad.list_tags(face) for face in faces))
|
||||
|
||||
def test_cylinder_faces_have_new_tags(self):
|
||||
cylinder = scad.make_cylinder_rsolid(1.0, 2.0)
|
||||
cylinder.auto_tag_faces("cylinder")
|
||||
faces = cylinder.get_faces()
|
||||
self.assertTrue(any("face.top" in scad.list_tags(face) for face in faces))
|
||||
self.assertTrue(any("face.bottom" in scad.list_tags(face) for face in faces))
|
||||
self.assertTrue(any("face.side" in scad.list_tags(face) for face in faces))
|
||||
|
||||
def test_sphere_faces_have_new_tags(self):
|
||||
sphere = scad.make_sphere_rsolid(1.0)
|
||||
sphere.auto_tag_faces("sphere")
|
||||
faces = sphere.get_faces()
|
||||
self.assertEqual(len(faces), 1)
|
||||
self.assertIn("face.surface", scad.list_tags(faces[0]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Tests for BRep tracking: boolean operation history capture via OCC Modified/Generated/IsDeleted."""
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.topology import (
|
||||
TopoKind,
|
||||
TopoEvent,
|
||||
TopoRef,
|
||||
TopoDelta,
|
||||
OperationNode,
|
||||
OperationGraph,
|
||||
)
|
||||
from simplecadapi.tracking import (
|
||||
tracked_cut,
|
||||
tracked_union,
|
||||
tracked_intersect,
|
||||
TrackedBooleanResult,
|
||||
)
|
||||
|
||||
|
||||
class TestTrackedCut(unittest.TestCase):
|
||||
"""Test cut with full face-level history."""
|
||||
|
||||
def setUp(self):
|
||||
self.body = scad.make_box_rsolid(10, 10, 10)
|
||||
self.tool = scad.make_cylinder_rsolid(
|
||||
2.0, 15.0, bottom_face_center=(3, 3, -2.5)
|
||||
)
|
||||
|
||||
def test_tracked_cut_returns_solid_and_delta(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
self.assertIsInstance(result, TrackedBooleanResult)
|
||||
self.assertIsNotNone(result.solid)
|
||||
self.assertIsInstance(result.delta, TopoDelta)
|
||||
|
||||
def test_tracked_cut_has_preserved_faces(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
preserved_face_refs = [
|
||||
r for r in result.delta.preserved if r.kind == TopoKind.FACE
|
||||
]
|
||||
# After a cylinder cut through a box, most original faces should be
|
||||
# either modified or preserved; some should survive
|
||||
self.assertGreater(len(preserved_face_refs), 0)
|
||||
|
||||
def test_tracked_cut_has_generated_faces(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
generated_face_refs = [
|
||||
r for r in result.delta.generated if r.kind == TopoKind.FACE
|
||||
]
|
||||
# The cylindrical hole creates new faces
|
||||
self.assertGreater(len(generated_face_refs), 0)
|
||||
|
||||
def test_tracked_cut_volume_decreased(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
original_vol = self.body.get_volume()
|
||||
result_vol = result.solid.get_volume()
|
||||
self.assertLess(result_vol, original_vol)
|
||||
|
||||
def test_tracked_cut_total_faces_increased(self):
|
||||
"""A cylinder cut through a box adds the cylindrical hole face."""
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
original_faces = len(self.body.get_faces())
|
||||
result_faces = len(result.solid.get_faces())
|
||||
# Cylinder through box: at least 1 new face (cylindrical hole)
|
||||
self.assertGreaterEqual(result_faces, original_faces)
|
||||
|
||||
def test_tracked_cut_tool_with_tool_face_labels(self):
|
||||
"""Faces from the tool should be labeled with origin_role='tool'."""
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
tool_generated = [
|
||||
r
|
||||
for r in result.delta.generated
|
||||
if r.kind == TopoKind.FACE
|
||||
and result.delta_entries.get(r.topo_id, {}).get("origin_role") == "tool"
|
||||
]
|
||||
# At least the cylindrical face of the hole should be tool-origin
|
||||
self.assertGreater(len(tool_generated), 0)
|
||||
|
||||
def test_tracked_cut_preserves_volume_accuracy(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
# Volume should be original minus roughly the cylinder volume
|
||||
expected_min = self.body.get_volume() - 3.14159 * 2.0**2 * 15.0
|
||||
self.assertGreater(result.solid.get_volume(), 0)
|
||||
|
||||
|
||||
class TestTrackedUnion(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.body = scad.make_box_rsolid(10, 10, 10)
|
||||
# Use a cylinder to ensure curved intersection edges
|
||||
self.tool = scad.make_cylinder_rsolid(4.0, 10.0, bottom_face_center=(3, 3, 0))
|
||||
|
||||
def test_tracked_union_returns_solid_and_delta(self):
|
||||
result = tracked_union(self.body, self.tool)
|
||||
self.assertIsInstance(result, TrackedBooleanResult)
|
||||
self.assertIsNotNone(result.solid)
|
||||
|
||||
def test_tracked_union_volume_increased(self):
|
||||
result = tracked_union(self.body, self.tool)
|
||||
original_vol = self.body.get_volume()
|
||||
result_vol = result.solid.get_volume()
|
||||
# Union of two overlapping boxes should be less than sum but more than either
|
||||
self.assertGreater(result_vol, original_vol)
|
||||
|
||||
def test_tracked_union_has_section_edges(self):
|
||||
# OCC may suppress SectionEdges under glue mode; disabling glue should expose them.
|
||||
result = tracked_union(self.body, self.tool, glue=False)
|
||||
section_edges = result.delta.section_edges
|
||||
self.assertGreater(len(section_edges), 0)
|
||||
|
||||
|
||||
class TestTrackedIntersect(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.body = scad.make_box_rsolid(10, 10, 10)
|
||||
self.tool = scad.make_cylinder_rsolid(6.0, 10.0, bottom_face_center=(3, 3, 0))
|
||||
|
||||
def test_tracked_intersect_returns_solid_and_delta(self):
|
||||
result = tracked_intersect(self.body, self.tool)
|
||||
self.assertIsInstance(result, TrackedBooleanResult)
|
||||
self.assertIsNotNone(result.solid)
|
||||
|
||||
def test_tracked_intersect_volume_less_than_both(self):
|
||||
result = tracked_intersect(self.body, self.tool)
|
||||
self.assertLess(result.solid.get_volume(), self.body.get_volume())
|
||||
self.assertLess(result.solid.get_volume(), self.tool.get_volume())
|
||||
|
||||
|
||||
class TestDeltaEntries(unittest.TestCase):
|
||||
"""Test that delta_entries provides origin_role info."""
|
||||
|
||||
def setUp(self):
|
||||
self.body = scad.make_box_rsolid(10, 10, 10)
|
||||
self.tool = scad.make_cylinder_rsolid(
|
||||
3.0, 15.0, bottom_face_center=(3, 3, -2.5)
|
||||
)
|
||||
|
||||
def test_body_faces_labeled(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
body_preserved = [
|
||||
r
|
||||
for r in result.delta.preserved
|
||||
if r.kind == TopoKind.FACE
|
||||
and result.delta_entries.get(r.topo_id, {}).get("origin_role") == "body"
|
||||
]
|
||||
self.assertGreater(len(body_preserved), 0)
|
||||
|
||||
def test_modified_faces_labeled(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
body_modified = [
|
||||
r
|
||||
for r in result.delta.modified
|
||||
if r.kind == TopoKind.FACE
|
||||
and result.delta_entries.get(r.topo_id, {}).get("origin_role") == "body"
|
||||
]
|
||||
# Some faces from body should be modified
|
||||
self.assertGreater(len(body_modified), 0)
|
||||
|
||||
|
||||
class TestSolidMapping(unittest.TestCase):
|
||||
"""Test that the result solid is a valid SimpleCADAPI Solid."""
|
||||
|
||||
def setUp(self):
|
||||
self.body = scad.make_box_rsolid(10, 10, 10)
|
||||
self.tool = scad.make_cylinder_rsolid(
|
||||
2.0, 15.0, bottom_face_center=(3, 3, -2.5)
|
||||
)
|
||||
|
||||
def test_result_is_solid(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
self.assertIsInstance(result.solid, scad.Solid)
|
||||
|
||||
def test_result_has_faces(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
faces = result.solid.get_faces()
|
||||
self.assertGreater(len(faces), 0)
|
||||
|
||||
def test_result_has_valid_volume(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
self.assertGreater(result.solid.get_volume(), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Tests for tracking data models: TopoRef, TopoEvent, TopoDelta, OperationNode, OperationGraph."""
|
||||
|
||||
import unittest
|
||||
|
||||
from simplecadapi.topology import (
|
||||
TopoKind,
|
||||
TopoEvent,
|
||||
TopoRef,
|
||||
TopoEntry,
|
||||
TopoDelta,
|
||||
OperationNode,
|
||||
OperationGraph,
|
||||
)
|
||||
|
||||
|
||||
class TestTopoRef(unittest.TestCase):
|
||||
def test_fields(self):
|
||||
ref = TopoRef("g1", "n1", 0, TopoKind.FACE, "f42")
|
||||
self.assertEqual(ref.graph_id, "g1")
|
||||
self.assertEqual(ref.node_id, "n1")
|
||||
self.assertEqual(ref.output_slot, 0)
|
||||
self.assertEqual(ref.kind, TopoKind.FACE)
|
||||
self.assertEqual(ref.topo_id, "f42")
|
||||
|
||||
def test_equality(self):
|
||||
a = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
b = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
self.assertEqual(a, b)
|
||||
|
||||
def test_inequality_different_topo_id(self):
|
||||
a = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
b = TopoRef("g1", "n1", 0, TopoKind.FACE, "f2")
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
def test_inequality_different_node(self):
|
||||
a = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
b = TopoRef("g1", "n2", 0, TopoKind.FACE, "f1")
|
||||
self.assertNotEqual(a, b)
|
||||
|
||||
def test_hashable(self):
|
||||
a = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
b = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
s = {a, b}
|
||||
self.assertEqual(len(s), 1)
|
||||
|
||||
def test_hash_unequal(self):
|
||||
a = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
b = TopoRef("g1", "n1", 0, TopoKind.FACE, "f2")
|
||||
s = {a, b}
|
||||
self.assertEqual(len(s), 2)
|
||||
|
||||
|
||||
class TestTopoEntry(unittest.TestCase):
|
||||
def test_preserved(self):
|
||||
ref = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
entry = TopoEntry(ref, TopoEvent.PRESERVED, origin_role="body")
|
||||
self.assertEqual(entry.event, TopoEvent.PRESERVED)
|
||||
self.assertEqual(entry.origin_role, "body")
|
||||
self.assertEqual(entry.parent_refs, ())
|
||||
|
||||
def test_generated_with_parents(self):
|
||||
ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_new")
|
||||
parent = TopoRef("g1", "n1", 0, TopoKind.FACE, "f1")
|
||||
entry = TopoEntry(
|
||||
ref, TopoEvent.GENERATED, origin_role="body", parent_refs=(parent,)
|
||||
)
|
||||
self.assertEqual(entry.event, TopoEvent.GENERATED)
|
||||
self.assertEqual(len(entry.parent_refs), 1)
|
||||
self.assertEqual(entry.parent_refs[0], parent)
|
||||
|
||||
def test_deleted(self):
|
||||
ref = TopoRef("g1", "n1", 0, TopoKind.FACE, "f_gone")
|
||||
entry = TopoEntry(ref, TopoEvent.DELETED)
|
||||
self.assertEqual(entry.event, TopoEvent.DELETED)
|
||||
self.assertIsNone(entry.origin_role)
|
||||
|
||||
|
||||
class TestTopoDelta(unittest.TestCase):
|
||||
def test_preserved_and_generated(self):
|
||||
preserved_ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_keep")
|
||||
generated_ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_new")
|
||||
delta = TopoDelta(
|
||||
preserved=[preserved_ref],
|
||||
generated=[generated_ref],
|
||||
)
|
||||
self.assertEqual(len(delta.preserved), 1)
|
||||
self.assertEqual(len(delta.modified), 0)
|
||||
self.assertEqual(len(delta.generated), 1)
|
||||
self.assertEqual(len(delta.deleted), 0)
|
||||
|
||||
def test_modified_and_deleted(self):
|
||||
mod_ref = TopoRef("g1", "n2", 0, TopoKind.FACE, "f_mod")
|
||||
del_ref = TopoRef("g1", "n1", 0, TopoKind.FACE, "f_del")
|
||||
delta = TopoDelta(
|
||||
modified=[mod_ref],
|
||||
deleted=[del_ref],
|
||||
)
|
||||
self.assertEqual(len(delta.modified), 1)
|
||||
self.assertEqual(len(delta.deleted), 1)
|
||||
|
||||
def test_section_edges(self):
|
||||
edge_ref = TopoRef("g1", "n2", 0, TopoKind.EDGE, "e_section")
|
||||
delta = TopoDelta(section_edges=[edge_ref])
|
||||
self.assertEqual(len(delta.section_edges), 1)
|
||||
self.assertEqual(delta.section_edges[0].kind, TopoKind.EDGE)
|
||||
|
||||
def test_empty(self):
|
||||
delta = TopoDelta()
|
||||
self.assertEqual(delta.preserved, ())
|
||||
self.assertEqual(delta.modified, ())
|
||||
self.assertEqual(delta.generated, ())
|
||||
self.assertEqual(delta.deleted, ())
|
||||
self.assertEqual(delta.section_edges, ())
|
||||
|
||||
|
||||
class TestOperationNode(unittest.TestCase):
|
||||
def test_creation(self):
|
||||
node = OperationNode(
|
||||
node_id="n1",
|
||||
op="make_line_redge",
|
||||
params={"start": (0, 0, 0), "end": (10, 0, 0)},
|
||||
)
|
||||
self.assertEqual(node.node_id, "n1")
|
||||
self.assertEqual(node.op, "make_line_redge")
|
||||
self.assertEqual(node.params["end"], (10, 0, 0))
|
||||
self.assertEqual(node.inputs, ())
|
||||
self.assertIsNone(node.topo_delta)
|
||||
|
||||
def test_with_inputs(self):
|
||||
node1 = OperationNode("n1", "make_line_redge", {})
|
||||
node2 = OperationNode("n2", "make_wire_from_edges_rwire", {}, inputs=(node1,))
|
||||
self.assertEqual(len(node2.inputs), 1)
|
||||
self.assertEqual(node2.inputs[0].node_id, "n1")
|
||||
|
||||
def test_with_topo_delta(self):
|
||||
delta = TopoDelta(generated=[TopoRef("g1", "n1", 0, TopoKind.FACE, "f_new")])
|
||||
node = OperationNode("n1", "make_extrude_rsolid", {}, topo_delta=delta)
|
||||
self.assertIsNotNone(node.topo_delta)
|
||||
self.assertEqual(len(node.topo_delta.generated), 1)
|
||||
|
||||
def test_output_count(self):
|
||||
node = OperationNode("n1", "make_translate_rshape", {}, output_count=2)
|
||||
self.assertEqual(node.output_count, 2)
|
||||
|
||||
|
||||
class TestOperationGraph(unittest.TestCase):
|
||||
def test_empty_graph(self):
|
||||
graph = OperationGraph()
|
||||
self.assertEqual(graph.node_count, 0)
|
||||
self.assertEqual(graph.edge_count, 0)
|
||||
self.assertEqual(graph.nodes, [])
|
||||
|
||||
def test_add_primitive_node(self):
|
||||
graph = OperationGraph()
|
||||
node = graph.add_node(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)}
|
||||
)
|
||||
self.assertEqual(graph.node_count, 1)
|
||||
self.assertEqual(node.op, "make_line_redge")
|
||||
self.assertEqual(node.params["end"], (10, 0, 0))
|
||||
|
||||
def test_add_node_with_inputs(self):
|
||||
graph = OperationGraph()
|
||||
box_node = graph.add_node(
|
||||
"make_line_redge", {"start": (0, 0, 0), "end": (10, 0, 0)}
|
||||
)
|
||||
cyl_node = graph.add_node(
|
||||
"make_line_redge", {"start": (10, 0, 0), "end": (10, 10, 0)}
|
||||
)
|
||||
cut_node = graph.add_node(
|
||||
"make_wire_from_edges_rwire", {"edge_count": 2}, inputs=[box_node, cyl_node]
|
||||
)
|
||||
self.assertEqual(graph.node_count, 3)
|
||||
self.assertEqual(graph.edge_count, 2)
|
||||
self.assertEqual(len(cut_node.inputs), 2)
|
||||
|
||||
def test_get_node(self):
|
||||
graph = OperationGraph()
|
||||
node = graph.add_node("make_line_redge", {})
|
||||
found = graph.get_node(node.node_id)
|
||||
self.assertEqual(found, node)
|
||||
|
||||
def test_get_node_missing(self):
|
||||
graph = OperationGraph()
|
||||
self.assertIsNone(graph.get_node("nonexistent"))
|
||||
|
||||
def test_unique_ids(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
n2 = graph.add_node("make_line_redge", {})
|
||||
self.assertNotEqual(n1.node_id, n2.node_id)
|
||||
|
||||
def test_edges_are_set(self):
|
||||
graph = OperationGraph()
|
||||
box_node = graph.add_node("make_line_redge", {})
|
||||
cyl_node = graph.add_node("make_line_redge", {})
|
||||
graph.add_node("make_wire_from_edges_rwire", {}, inputs=[box_node, cyl_node])
|
||||
edges = graph.edges
|
||||
# Should not have duplicate edges
|
||||
self.assertEqual(len(edges), len(set(edges)))
|
||||
|
||||
def test_upstream_nodes(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
n2 = graph.add_node("make_line_redge", {})
|
||||
n3 = graph.add_node("make_wire_from_edges_rwire", {}, inputs=[n1, n2])
|
||||
upstream = graph.upstream_nodes(n3.node_id)
|
||||
self.assertEqual(len(upstream), 2)
|
||||
self.assertIn(n1.node_id, upstream)
|
||||
self.assertIn(n2.node_id, upstream)
|
||||
|
||||
def test_upstream_empty(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
self.assertEqual(graph.upstream_nodes(n1.node_id), [])
|
||||
|
||||
def test_downstream_nodes(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
n2 = graph.add_node("make_line_redge", {})
|
||||
n3 = graph.add_node("make_wire_from_edges_rwire", {}, inputs=[n1, n2])
|
||||
downstream = graph.downstream_nodes(n1.node_id)
|
||||
self.assertEqual(len(downstream), 1)
|
||||
self.assertEqual(downstream[0], n3.node_id)
|
||||
|
||||
def test_is_dag_valid(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
n2 = graph.add_node("make_line_redge", {})
|
||||
n3 = graph.add_node("make_wire_from_edges_rwire", {}, inputs=[n1, n2])
|
||||
self.assertTrue(graph.is_dag())
|
||||
|
||||
def test_is_dag_empty(self):
|
||||
graph = OperationGraph()
|
||||
self.assertTrue(graph.is_dag())
|
||||
|
||||
def test_root_nodes(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
n2 = graph.add_node("make_line_redge", {})
|
||||
n3 = graph.add_node("make_wire_from_edges_rwire", {}, inputs=[n1, n2])
|
||||
roots = graph.root_nodes()
|
||||
self.assertEqual(len(roots), 2)
|
||||
root_ids = [r.node_id for r in roots]
|
||||
self.assertIn(n1.node_id, root_ids)
|
||||
self.assertIn(n2.node_id, root_ids)
|
||||
|
||||
def test_leaf_nodes(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
n2 = graph.add_node("make_line_redge", {})
|
||||
n3 = graph.add_node("make_wire_from_edges_rwire", {}, inputs=[n1, n2])
|
||||
leaves = graph.leaf_nodes()
|
||||
self.assertEqual(len(leaves), 1)
|
||||
self.assertEqual(leaves[0].node_id, n3.node_id)
|
||||
|
||||
def test_topological_order(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
n2 = graph.add_node("make_line_redge", {})
|
||||
n3 = graph.add_node("make_wire_from_edges_rwire", {}, inputs=[n1, n2])
|
||||
n4 = graph.add_node("make_face_from_wire_rface", {}, inputs=[n3])
|
||||
topo = graph.topological_order()
|
||||
self.assertEqual(len(topo), 4)
|
||||
# n1 and n2 before n3, n3 before n4
|
||||
idx = {node.node_id: i for i, node in enumerate(topo)}
|
||||
self.assertLess(idx[n1.node_id], idx[n3.node_id])
|
||||
self.assertLess(idx[n2.node_id], idx[n3.node_id])
|
||||
self.assertLess(idx[n3.node_id], idx[n4.node_id])
|
||||
|
||||
def test_topological_order_single(self):
|
||||
graph = OperationGraph()
|
||||
n1 = graph.add_node("make_line_redge", {})
|
||||
topo = graph.topological_order()
|
||||
self.assertEqual(len(topo), 1)
|
||||
self.assertEqual(topo[0].node_id, n1.node_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for tracking transforms and feature operations."""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.topology import TopoKind, TopoEvent, TopoRef, TopoDelta
|
||||
from simplecadapi.tracking import (
|
||||
tracked_translate,
|
||||
tracked_rotate,
|
||||
tracked_extrude,
|
||||
tracked_fillet,
|
||||
tracked_chamfer,
|
||||
TrackedResult,
|
||||
)
|
||||
|
||||
|
||||
class TestTrackedTranslate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.box = scad.make_box_rsolid(10, 10, 10)
|
||||
|
||||
def test_translate_returns_tracked_result(self):
|
||||
result = tracked_translate(self.box, (5, 0, 0))
|
||||
self.assertIsInstance(result, TrackedResult)
|
||||
self.assertIsNotNone(result.shape)
|
||||
|
||||
def test_translate_preserves_volume(self):
|
||||
result = tracked_translate(self.box, (5, 0, 0))
|
||||
self.assertAlmostEqual(
|
||||
result.shape.get_volume(), self.box.get_volume(), places=6
|
||||
)
|
||||
|
||||
def test_translate_all_faces_preserved(self):
|
||||
result = tracked_translate(self.box, (5, 0, 0))
|
||||
preserved = [r for r in result.delta.preserved if r.kind == TopoKind.FACE]
|
||||
original_faces = len(self.box.get_faces())
|
||||
self.assertEqual(len(preserved), original_faces)
|
||||
|
||||
def test_translate_no_generated(self):
|
||||
result = tracked_translate(self.box, (5, 0, 0))
|
||||
self.assertEqual(len(result.delta.generated), 0)
|
||||
self.assertEqual(len(result.delta.deleted), 0)
|
||||
|
||||
def test_translate_result_is_solid(self):
|
||||
result = tracked_translate(self.box, (5, 0, 0))
|
||||
self.assertIsInstance(result.shape, scad.Solid)
|
||||
|
||||
|
||||
class TestTrackedRotate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.box = scad.make_box_rsolid(10, 10, 10)
|
||||
|
||||
def test_rotate_returns_tracked_result(self):
|
||||
result = tracked_rotate(self.box, 45.0, (0, 0, 1))
|
||||
self.assertIsInstance(result, TrackedResult)
|
||||
self.assertIsNotNone(result.shape)
|
||||
|
||||
def test_rotate_preserves_volume(self):
|
||||
result = tracked_rotate(self.box, 45.0, (0, 0, 1))
|
||||
self.assertAlmostEqual(
|
||||
result.shape.get_volume(), self.box.get_volume(), places=4
|
||||
)
|
||||
|
||||
def test_rotate_all_faces_preserved(self):
|
||||
result = tracked_rotate(self.box, 45.0, (0, 0, 1))
|
||||
preserved = [r for r in result.delta.preserved if r.kind == TopoKind.FACE]
|
||||
original_faces = len(self.box.get_faces())
|
||||
self.assertEqual(len(preserved), original_faces)
|
||||
|
||||
def test_rotate_no_generated(self):
|
||||
result = tracked_rotate(self.box, 45.0, (0, 0, 1))
|
||||
self.assertEqual(len(result.delta.generated), 0)
|
||||
|
||||
|
||||
class TestTrackedExtrude(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.profile = scad.make_rectangle_rface(5.0, 3.0)
|
||||
|
||||
def test_extrude_returns_tracked_result(self):
|
||||
result = tracked_extrude(self.profile, (0, 0, 1), 10.0)
|
||||
self.assertIsInstance(result, TrackedResult)
|
||||
self.assertIsNotNone(result.shape)
|
||||
|
||||
def test_extrude_has_correct_volume(self):
|
||||
result = tracked_extrude(self.profile, (0, 0, 1), 10.0)
|
||||
expected_vol = self.profile.get_area() * 10.0
|
||||
self.assertAlmostEqual(result.shape.get_volume(), expected_vol, places=5)
|
||||
|
||||
def test_extrude_generates_new_faces(self):
|
||||
result = tracked_extrude(self.profile, (0, 0, 1), 10.0)
|
||||
self.assertGreater(len(result.delta.generated), 0)
|
||||
|
||||
def test_extrude_has_modified_or_preserved_face(self):
|
||||
result = tracked_extrude(self.profile, (0, 0, 1), 10.0)
|
||||
# The profile face becomes part of the extruded solid (either modified or
|
||||
# generated depending on OCC version)
|
||||
has_changes = (
|
||||
len(result.delta.modified) > 0
|
||||
or len(result.delta.preserved) > 0
|
||||
or len(result.delta.generated) > 0
|
||||
)
|
||||
self.assertTrue(has_changes)
|
||||
|
||||
|
||||
class TestTrackedFillet(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.box = scad.make_box_rsolid(10, 10, 10)
|
||||
# Select some edges for filleting
|
||||
self.edges = [self.box.get_edges(i) for i in range(4)]
|
||||
|
||||
def test_fillet_returns_tracked_result(self):
|
||||
result = tracked_fillet(self.box, self.edges, 0.5)
|
||||
self.assertIsInstance(result, TrackedResult)
|
||||
self.assertIsNotNone(result.shape)
|
||||
|
||||
def test_fillet_volume_decreased(self):
|
||||
result = tracked_fillet(self.box, self.edges, 0.5)
|
||||
self.assertLess(result.shape.get_volume(), self.box.get_volume())
|
||||
|
||||
def test_fillet_has_modified_faces(self):
|
||||
result = tracked_fillet(self.box, self.edges, 0.5)
|
||||
# Fillet modifies edge-adjacent faces; OCC may report new toroidal faces
|
||||
# as Modified rather than Generated
|
||||
total_changes = len(result.delta.modified) + len(result.delta.generated)
|
||||
self.assertGreater(total_changes, 0)
|
||||
|
||||
|
||||
class TestTrackedChamfer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.box = scad.make_box_rsolid(10, 10, 10)
|
||||
self.edges = [self.box.get_edges(i) for i in range(4)]
|
||||
|
||||
def test_chamfer_returns_tracked_result(self):
|
||||
result = tracked_chamfer(self.box, self.edges, 0.5)
|
||||
self.assertIsInstance(result, TrackedResult)
|
||||
self.assertIsNotNone(result.shape)
|
||||
|
||||
def test_chamfer_volume_decreased(self):
|
||||
result = tracked_chamfer(self.box, self.edges, 0.5)
|
||||
self.assertLess(result.shape.get_volume(), self.box.get_volume())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Tests for auto-tagging based on TopoDelta."""
|
||||
|
||||
import unittest
|
||||
|
||||
import simplecadapi as scad
|
||||
from simplecadapi.topology import TopoKind, TopoEvent
|
||||
from simplecadapi.tracking import tracked_cut, tracked_union, tracked_extrude
|
||||
from simplecadapi.autotag import apply_tracking_tags, apply_tracking_tags_to_delta
|
||||
|
||||
|
||||
class TestAutoTagCut(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.body = scad.make_box_rsolid(10, 10, 10)
|
||||
self.tool = scad.make_cylinder_rsolid(
|
||||
2.0, 15.0, bottom_face_center=(3, 3, -2.5)
|
||||
)
|
||||
|
||||
def test_cut_result_faces_get_operation_tags(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
tagged_solid = apply_tracking_tags_to_delta(
|
||||
result.solid, result.delta, result.delta_entries, op="cut"
|
||||
)
|
||||
faces = tagged_solid.get_faces()
|
||||
# At least some faces should have operation event tags
|
||||
has_op_tag = any(
|
||||
"op.cut.modified" in scad.list_tags(f) or "op.cut.generated" in scad.list_tags(f) for f in faces
|
||||
)
|
||||
self.assertTrue(has_op_tag)
|
||||
|
||||
def test_cut_preserved_faces_tagged(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
tagged_solid = apply_tracking_tags_to_delta(
|
||||
result.solid, result.delta, result.delta_entries, op="cut"
|
||||
)
|
||||
faces = tagged_solid.get_faces()
|
||||
preserved = [f for f in faces if "op.cut.preserved" in scad.list_tags(f)]
|
||||
self.assertGreater(len(preserved), 0)
|
||||
|
||||
def test_cut_all_faces_tagged(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
tagged_solid = apply_tracking_tags_to_delta(
|
||||
result.solid, result.delta, result.delta_entries, op="cut"
|
||||
)
|
||||
faces = tagged_solid.get_faces()
|
||||
# All result faces should have some operation event tag
|
||||
all_tagged = all(
|
||||
"op.cut.modified" in scad.list_tags(f)
|
||||
or "op.cut.preserved" in scad.list_tags(f)
|
||||
or "op.cut.generated" in scad.list_tags(f)
|
||||
for f in faces
|
||||
)
|
||||
self.assertTrue(all_tagged)
|
||||
|
||||
def test_cut_origin_role_tags(self):
|
||||
result = tracked_cut(self.body, self.tool)
|
||||
tagged_solid = apply_tracking_tags_to_delta(
|
||||
result.solid, result.delta, result.delta_entries, op="cut"
|
||||
)
|
||||
faces = tagged_solid.get_faces()
|
||||
has_body = any("origin.body" in scad.list_tags(f) for f in faces)
|
||||
has_tool = any("origin.tool" in scad.list_tags(f) for f in faces)
|
||||
self.assertTrue(has_body)
|
||||
self.assertTrue(has_tool)
|
||||
|
||||
|
||||
class TestAutoTagUnion(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.body = scad.make_box_rsolid(10, 10, 10)
|
||||
self.tool = scad.make_cylinder_rsolid(4.0, 10.0, bottom_face_center=(3, 3, 0))
|
||||
|
||||
def test_union_section_faces_tagged(self):
|
||||
result = tracked_union(self.body, self.tool)
|
||||
tagged_solid = apply_tracking_tags_to_delta(
|
||||
result.solid, result.delta, result.delta_entries, op="union"
|
||||
)
|
||||
faces = tagged_solid.get_faces()
|
||||
# Union creates modified faces at the intersection
|
||||
has_union_tag = any(
|
||||
"op.union.modified" in scad.list_tags(f) or "op.union.preserved" in scad.list_tags(f)
|
||||
for f in faces
|
||||
)
|
||||
self.assertTrue(has_union_tag)
|
||||
|
||||
|
||||
class TestAutoTagExtrude(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.profile = scad.make_rectangle_rface(5.0, 3.0)
|
||||
|
||||
def test_extrude_faces_tagged(self):
|
||||
result = tracked_extrude(self.profile, (0, 0, 1), 10.0)
|
||||
tagged_solid = apply_tracking_tags_to_delta(
|
||||
result.shape, result.delta, result.delta_entries, op="extrude"
|
||||
)
|
||||
faces = tagged_solid.get_faces()
|
||||
# Extrude produces new faces; any operation tag is sufficient
|
||||
has_tag = any(
|
||||
"op.extrude.generated" in scad.list_tags(f) or "op.extrude.modified" in scad.list_tags(f)
|
||||
for f in faces
|
||||
)
|
||||
self.assertTrue(has_tag)
|
||||
|
||||
|
||||
class TestAutoTagPreservesExisting(unittest.TestCase):
|
||||
def test_existing_tags_carried_to_result(self):
|
||||
"""Tags from the original body should be carried to preserved/modified faces."""
|
||||
body = scad.make_box_rsolid(10, 10, 10)
|
||||
body.auto_tag_faces("box")
|
||||
tool = scad.make_cylinder_rsolid(2.0, 15.0, bottom_face_center=(3, 3, -2.5))
|
||||
result = tracked_cut(body, tool)
|
||||
tagged_solid = apply_tracking_tags_to_delta(
|
||||
result.solid,
|
||||
result.delta,
|
||||
result.delta_entries,
|
||||
op="cut",
|
||||
source_solid=body,
|
||||
)
|
||||
faces = tagged_solid.get_faces()
|
||||
# Preserved and modified faces from body should carry body's tags
|
||||
has_original = any(
|
||||
"face.top" in scad.list_tags(f) or "face.bottom" in scad.list_tags(f) for f in faces
|
||||
)
|
||||
self.assertTrue(has_original)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
import simplecadapi as scad
|
||||
|
||||
|
||||
def _two_box_assembly(offset):
|
||||
box = scad.make_box_rsolid(width=1.0, height=1.0, depth=1.0)
|
||||
part = scad.make_part_rpart(part_id="box_part", body=box)
|
||||
assembly = scad.make_assembly_rassembly(assembly_id="collision_demo")
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly=assembly,
|
||||
item=part,
|
||||
component_id="box_a",
|
||||
placement=scad.identity_placement_rplacement(),
|
||||
)
|
||||
assembly = scad.add_component_rassembly(
|
||||
assembly=assembly,
|
||||
item=part,
|
||||
component_id="box_b",
|
||||
placement=scad.make_placement_rplacement(origin=offset),
|
||||
)
|
||||
return assembly
|
||||
|
||||
|
||||
def test_verifier_namespace_is_public():
|
||||
assert hasattr(scad, "verifier")
|
||||
assert "verifier" in scad.__all__
|
||||
assert hasattr(scad.verifier, "check_collision_rcollisionreport")
|
||||
|
||||
|
||||
def test_collision_report_passes_for_separated_meshes():
|
||||
assembly = _two_box_assembly(offset=(2.0, 0.0, 0.0))
|
||||
|
||||
report = scad.verifier.check_collision_rcollisionreport(
|
||||
assembly=assembly,
|
||||
config=scad.verifier.CollisionCheckConfig(max_allowed_penetration=0.01),
|
||||
)
|
||||
|
||||
assert report.completed
|
||||
assert report.passed
|
||||
assert report.checked_pair_count == 1
|
||||
assert report.failed_pair_count == 0
|
||||
assert report.failures == ()
|
||||
|
||||
|
||||
def test_collision_report_fails_for_over_tolerance_contact_penetration():
|
||||
assembly = _two_box_assembly(offset=(0.5, 0.0, 0.0))
|
||||
|
||||
report = scad.verifier.check_collision_rcollisionreport(
|
||||
assembly=assembly,
|
||||
config=scad.verifier.CollisionCheckConfig(max_allowed_penetration=0.01),
|
||||
)
|
||||
|
||||
assert report.completed
|
||||
assert not report.passed
|
||||
assert report.checked_pair_count == 1
|
||||
assert report.failed_pair_count == 1
|
||||
failure = report.failures[0]
|
||||
assert failure.component_a == ("box_a",)
|
||||
assert failure.component_b == ("box_b",)
|
||||
assert failure.penetration_depth > 0.01
|
||||
assert failure.kind == "contact_penetration"
|
||||
assert failure.contacts
|
||||
|
||||
|
||||
def test_collision_report_respects_allowed_penetration_tolerance():
|
||||
assembly = _two_box_assembly(offset=(0.5, 0.0, 0.0))
|
||||
|
||||
report = scad.verifier.check_collision_rcollisionreport(
|
||||
assembly=assembly,
|
||||
config=scad.verifier.CollisionCheckConfig(max_allowed_penetration=2.0),
|
||||
)
|
||||
|
||||
assert report.completed
|
||||
assert report.passed
|
||||
assert report.checked_pair_count == 1
|
||||
assert report.failed_pair_count == 0
|
||||
|
||||
|
||||
def test_collision_scope_can_exclude_pair():
|
||||
assembly = _two_box_assembly(offset=(0.5, 0.0, 0.0))
|
||||
|
||||
report = scad.verifier.check_collision_rcollisionreport(
|
||||
assembly=assembly,
|
||||
config=scad.verifier.CollisionCheckConfig(
|
||||
max_allowed_penetration=0.01,
|
||||
scope=scad.verifier.CollisionScope(
|
||||
exclude_pairs=(scad.verifier.ComponentPair("box_a", "box_b"),),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assert report.completed
|
||||
assert report.passed
|
||||
assert report.checked_pair_count == 0
|
||||
assert report.failed_pair_count == 0
|
||||
Reference in New Issue
Block a user