282 lines
11 KiB
Python
282 lines
11 KiB
Python
"""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() |