"""Smoke test for project_io save/load round-trip. Builds a small project (a Component with a sketch, an extrude body, a workplane, plus an assembly with two instances and a connector) and verifies that saving then loading it preserves the data. """ import os import sys import tempfile import unittest # Allow running this file directly: ``python tests/test_project_io.py``. sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src")) from fluency.io.project_io import save_project, load_project, _feature_to_dict, _feature_from_dict from fluency.models.data_model import ( Project, Component, Body, Sketch, Workplane, Assembly, Feature, ) class TestRevolveAxisSerialization(unittest.TestCase): """Revolve features persist their revolve axis across save/load.""" def test_axis_round_trip(self): sk = Sketch() feat = Feature( operation="revolve", angle=180.0, axis=(1, 0, 0), origin=(10.0, 5.0, 0.0), axis_line_id=7, ) feat.sketch = sk data = _feature_to_dict(feat) self.assertEqual(data["axis"], [1.0, 0.0, 0.0]) self.assertEqual(data["origin"], [10.0, 5.0, 0.0]) self.assertEqual(data["axis_line_id"], 7) restored = _feature_from_dict(data, {sk.id: sk}) self.assertEqual(tuple(restored.axis), (1.0, 0.0, 0.0)) self.assertEqual(tuple(restored.origin), (10.0, 5.0, 0.0)) self.assertEqual(restored.axis_line_id, 7) self.assertEqual(restored.angle, 180.0) def test_default_axis(self): """Old files without axis fields fall back to Z axis at the origin.""" sk = Sketch() data = _feature_to_dict(Feature(operation="revolve", angle=90.0)) restored = _feature_from_dict(data, {sk.id: sk}) self.assertEqual(tuple(restored.axis), (0, 0, 1)) self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0)) self.assertIsNone(restored.axis_line_id) # legacy file without the keys del data["axis"] del data["origin"] del data["axis_line_id"] restored = _feature_from_dict(data, {sk.id: sk}) self.assertEqual(tuple(restored.axis), (0, 0, 1)) self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0)) class TestProjectIO(unittest.TestCase): """Round-trip the same project through save/load and check equivalence.""" def _build_project(self) -> Project: project = Project(name="Test Project", description="A tiny test") project.file_path = None # simulate untitled comp = project.add_component(Component(name="Part1")) # Add a workplane. wp = Workplane( name="Top", origin=(0.0, 0.0, 0.0), normal=(0.0, 0.0, 1.0), x_dir=(1.0, 0.0, 0.0), ) comp.add_workplane(wp) # Add a sketch with a square. sk = comp.add_sketch() sk.occ_sketch.add_rectangle((0.0, 0.0), (10.0, 10.0)) sk.solve() # Build a face geometry for the sketch (needed for export / restore). faces = sk.occ_sketch.detect_faces() if faces: sk.geometry = sk.occ_sketch.build_face_geometry(faces[0]) # Extrude into a body. if sk.geometry: kernel = project.kernel body_shape = kernel.extrude(sk.geometry, height=20.0) body = comp.add_body( Body( name="Block", geometry=body_shape, source_sketch=sk, source_operation="extrude", ) ) body.color = (0.4, 0.2, 0.8) # Add an assembly with two instances and a mated connector pair. asm = project.add_assembly(Assembly(name="Asm1")) ac1 = asm.add_component_instance(comp.id, name="Inst1") ac2 = asm.add_component_instance(comp.id, name="Inst2") c1 = ac1.add_connector( position=(5.0, 5.0, 0.0), normal=(0.0, 0.0, 1.0), x_dir=(1.0, 0.0, 0.0), ) c2 = ac2.add_connector( position=(10.0, 10.0, 5.0), normal=(0.0, 0.0, -1.0), x_dir=(1.0, 0.0, 0.0), ) # Record a mated pair (UI normally does this on connector-pick). conn = asm.add_connection(ac1.id, ac2.id) conn.first_connector_id = c1.id conn.second_connector_id = c2.id c1.partner_ac_id = ac2.id c1.partner_connector_id = c2.id c2.partner_ac_id = ac1.id c2.partner_connector_id = c1.id c1.is_grounded = True return project def test_round_trip(self): original = self._build_project() with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "test.fluency") saved_path = save_project(original, path) self.assertTrue(os.path.exists(saved_path)) self.assertGreater(os.path.getsize(saved_path), 100) loaded, view_state = load_project(saved_path) # ── Project metadata ── self.assertEqual(loaded.name, "Test Project") self.assertEqual(loaded.description, "A tiny test") self.assertEqual(len(loaded.components), 1) self.assertEqual(len(loaded.assemblies), 1) # ── Component / Workplane / Sketch / Body ── comp = next(iter(loaded.components.values())) self.assertEqual(comp.name, "Part1") self.assertEqual(len(comp.workplanes), 1) self.assertEqual(len(comp.sketches), 1) self.assertEqual(len(comp.bodies), 1) sk = next(iter(comp.sketches.values())) self.assertIsNotNone(sk.occ_sketch) # OCCSketch replayed the rectangle: 4 points + 4 lines + 1 implicit # origin anchor (first-point-fix). We only check that the entities # exist. self.assertGreaterEqual(sk.occ_sketch.get_entity_count(), 4) # Solved geometry should round-trip through STEP. self.assertIsNotNone(sk.geometry) body = next(iter(comp.bodies.values())) self.assertIsNotNone(body.geometry) self.assertEqual(body.name, "Block") self.assertEqual(tuple(body.color), (0.4, 0.2, 0.8)) # BRep topology should still be valid. self.assertGreater(body.get_mesh(loaded.kernel, 0.5)[0].size, 0) # ── Assembly / connector / connection ── asm = next(iter(loaded.assemblies.values())) self.assertEqual(len(asm.components), 2) self.assertEqual(len(asm.connections), 1) ac1, ac2 = list(asm.components.values()) self.assertEqual(len(ac1.connectors), 1) conn = next(iter(ac1.connectors.values())) self.assertEqual(conn.position, (5.0, 5.0, 0.0)) # The grounded-reference flag was re-applied to the first connector. self.assertTrue(conn.is_grounded) # Rigid-group BFS should still link the two instances. self.assertEqual(set(asm.get_rigid_group(ac1.id)), {ac1.id, ac2.id}) class TestProjectIOWithConstraints(unittest.TestCase): """Round-trip with parametric constraints on the sketch. Builds a slightly more interesting sketch (rectangle with horizontal + vertical constraints + a distance) so the constraint-log replay path is exercised, not just the entity-construction path. """ def _build_project(self) -> Project: project = Project(name="Constraints Project", description="") comp = project.add_component(Component(name="Part1")) # A square with a 25.4mm horizontal distance + a vertical distance, # both anchored on a single fixed corner. This is the minimum # number of constraints to fully define a square in 2D. sk = comp.add_sketch() sk.occ_sketch.set_workplane( (0.0, 0.0, 0.0), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0), ) p1 = sk.occ_sketch.add_point(0.0, 0.0) # fixed anchor (auto) p2 = sk.occ_sketch.add_point(25.4, 0.0) p3 = sk.occ_sketch.add_point(25.4, 25.4) p4 = sk.occ_sketch.add_point(0.0, 25.4) sk.occ_sketch.add_line(p1, p2) sk.occ_sketch.add_line(p2, p3) sk.occ_sketch.add_line(p3, p4) sk.occ_sketch.add_line(p4, p1) # Constraint the right side to a known length (instead of relying # on the construction positions, which would just be redundant). sk.occ_sketch.constrain_distance(p2, p3, 25.4) sk.solve() sk.is_fully_constrained = sk.occ_sketch.is_fully_constrained() faces = sk.occ_sketch.detect_faces() if faces: sk.geometry = sk.occ_sketch.build_face_geometry(faces[0]) # View state to persist. view_state = { "active_tab": 0, "active_component_id": comp.id, "active_sketch_id": sk.id, "camera_eye": [50.0, 50.0, 50.0], "camera_at": [0.0, 0.0, 0.0], "camera_up": [0.0, 0.0, 1.0], "panel_focus": "sketch", "assembly_view_active": False, } self._view_state = view_state return project def test_round_trip_with_view_state(self): original = self._build_project() with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "constrained.fluency") save_project(original, path, view_state=self._view_state) loaded, view_state = load_project(path) # View state must survive (modulo float-to-list round-tripping). self.assertEqual(view_state.get("active_component_id"), self._view_state["active_component_id"]) self.assertEqual(view_state.get("active_sketch_id"), self._view_state["active_sketch_id"]) self.assertEqual(view_state.get("camera_eye"), self._view_state["camera_eye"]) self.assertEqual(view_state.get("panel_focus"), self._view_state["panel_focus"]) # Sketch must have replayed its constraints and remain solvable. comp = next(iter(loaded.components.values())) sk = next(iter(comp.sketches.values())) # Re-solve on the loaded sketch to confirm the post-replay # configuration is still consistent. self.assertTrue(sk.occ_sketch.solve()) # The right edge of the square should still be 25.4mm tall. import math for lid, line in sk.occ_sketch._lines.items(): sid, eid = line sx, sy = sk.occ_sketch._points[sid] ex, ey = sk.occ_sketch._points[eid] length = math.hypot(ex - sx, ey - sy) if abs(length) > 1e-3: self.assertAlmostEqual( length, 25.4, places=3, msg=f"Constraint replay broke: line {lid} = {length}", ) break if __name__ == "__main__": unittest.main()