- Added save file foramt
- Split main.py refactor
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""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
|
||||
from fluency.models.data_model import (
|
||||
Project,
|
||||
Component,
|
||||
Body,
|
||||
Workplane,
|
||||
Assembly,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user