first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -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
+172
View File
@@ -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)
+547
View File
@@ -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"])
+251
View File
@@ -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
+208
View File
@@ -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()
+205
View File
@@ -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