Merge pull request 'feat(assembly): fixed reference planes (Top/Front/Right) + solver docs' (#307) from feat/assembly-origin-planes into main
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 51s
Build and Test / build (push) Has been cancelled

Reviewed-on: #307
This commit was merged in pull request #307.
This commit is contained in:
2026-02-21 15:09:55 +00:00
14 changed files with 1259 additions and 9 deletions

View File

@@ -0,0 +1,230 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# /****************************************************************************
# *
# Copyright (c) 2025 Kindred Systems <development@kindred-systems.com> *
# *
# This file is part of FreeCAD. *
# *
# FreeCAD is free software: you can redistribute it and/or modify it *
# under the terms of the GNU Lesser General Public License as *
# published by the Free Software Foundation, either version 2.1 of the *
# License, or (at your option) any later version. *
# *
# FreeCAD is distributed in the hope that it will be useful, but *
# WITHOUT ANY WARRANTY; without even the implied warranty of *
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# Lesser General Public License for more details. *
# *
# You should have received a copy of the GNU Lesser General Public *
# License along with FreeCAD. If not, see *
# <https://www.gnu.org/licenses/>. *
# *
# ***************************************************************************/
"""
Tests for assembly origin reference planes.
Verifies that new assemblies have properly labeled, grounded origin planes
and that joints can reference them for solving.
"""
import os
import tempfile
import unittest
import FreeCAD as App
import JointObject
import UtilsAssembly
class TestAssemblyOriginPlanes(unittest.TestCase):
"""Tests for assembly origin planes (Top/Front/Right)."""
def setUp(self):
doc_name = self.__class__.__name__
if App.ActiveDocument:
if App.ActiveDocument.Name != doc_name:
App.newDocument(doc_name)
else:
App.newDocument(doc_name)
App.setActiveDocument(doc_name)
self.doc = App.ActiveDocument
self.assembly = self.doc.addObject("Assembly::AssemblyObject", "Assembly")
self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints")
def tearDown(self):
App.closeDocument(self.doc.Name)
# ── Helpers ─────────────────────────────────────────────────────
def _get_origin(self):
return self.assembly.Origin
def _make_box(self, x=0, y=0, z=0, size=10):
box = self.assembly.newObject("Part::Box", "Box")
box.Length = size
box.Width = size
box.Height = size
box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation())
return box
def _make_joint(self, joint_type, ref1, ref2):
joint = self.jointgroup.newObject("App::FeaturePython", "Joint")
JointObject.Joint(joint, joint_type)
refs = [
[ref1[0], ref1[1]],
[ref2[0], ref2[1]],
]
joint.Proxy.setJointConnectors(joint, refs)
return joint
# ── Structure tests ─────────────────────────────────────────────
def test_assembly_has_origin(self):
"""New assembly has an Origin with 3 planes, 3 axes, 1 point."""
origin = self._get_origin()
self.assertIsNotNone(origin)
self.assertTrue(origin.isDerivedFrom("App::LocalCoordinateSystem"))
planes = origin.planes()
self.assertEqual(len(planes), 3)
axes = origin.axes()
self.assertEqual(len(axes), 3)
def test_origin_planes_labeled(self):
"""Origin planes are labeled Top, Front, Right."""
origin = self._get_origin()
xy = origin.getXY()
xz = origin.getXZ()
yz = origin.getYZ()
self.assertEqual(xy.Label, "Top")
self.assertEqual(xz.Label, "Front")
self.assertEqual(yz.Label, "Right")
def test_origin_planes_have_correct_roles(self):
"""Origin planes retain correct internal Role names."""
origin = self._get_origin()
self.assertEqual(origin.getXY().Role, "XY_Plane")
self.assertEqual(origin.getXZ().Role, "XZ_Plane")
self.assertEqual(origin.getYZ().Role, "YZ_Plane")
# ── Grounding tests ─────────────────────────────────────────────
def test_origin_in_grounded_set(self):
"""Origin is part of the assembly's grounded set."""
grounded = self.assembly.getGroundedParts()
origin = self._get_origin()
grounded_names = {obj.Name for obj in grounded}
self.assertIn(origin.Name, grounded_names)
# ── Reference resolution tests ──────────────────────────────────
def test_getObject_resolves_origin_plane(self):
"""UtilsAssembly.getObject correctly resolves an origin plane ref."""
origin = self._get_origin()
xy = origin.getXY()
# Ref structure: [Origin, ["XY_Plane.", "XY_Plane."]]
ref = [origin, [xy.Name + ".", xy.Name + "."]]
obj = UtilsAssembly.getObject(ref)
self.assertEqual(obj, xy)
def test_findPlacement_origin_plane_returns_identity(self):
"""findPlacement for an origin plane (whole-object) returns identity."""
origin = self._get_origin()
xy = origin.getXY()
ref = [origin, [xy.Name + ".", xy.Name + "."]]
plc = UtilsAssembly.findPlacement(ref)
# For datum planes with no element, identity is returned.
# The actual orientation comes from the solver's getGlobalPlacement.
self.assertTrue(
plc.isSame(App.Placement(), 1e-6),
"findPlacement for origin plane should return identity",
)
# ── Joint / solve tests ─────────────────────────────────────────
def test_fixed_joint_to_origin_plane(self):
"""Fixed joint referencing an origin plane solves correctly."""
origin = self._get_origin()
xy = origin.getXY()
box = self._make_box(50, 50, 50)
# Fixed joint (type 0): origin XY plane ↔ box Face1 (bottom, Z=0)
self._make_joint(
0,
[origin, [xy.Name + ".", xy.Name + "."]],
[box, ["Face1", "Vertex1"]],
)
# After solve, the box should have moved so that its Face1 (bottom)
# aligns with the XY plane (Z=0). The box bottom vertex1 is at (0,0,0).
self.assertAlmostEqual(
box.Placement.Base.z,
0.0,
places=3,
msg="Box should be on XY plane after fixed joint to Top plane",
)
def test_solve_return_code_with_origin_plane(self):
"""Solve with an origin plane joint returns success (0)."""
origin = self._get_origin()
xz = origin.getXZ()
box = self._make_box(0, 100, 0)
self._make_joint(
0,
[origin, [xz.Name + ".", xz.Name + "."]],
[box, ["Face1", "Vertex1"]],
)
result = self.assembly.solve()
self.assertEqual(result, 0, "Solve should succeed with origin plane joint")
# ── Round-trip test ──────────────────────────────────────────────
def test_save_load_preserves_labels(self):
"""Labels survive save/load round-trip."""
origin = self._get_origin()
# Verify labels before save
self.assertEqual(origin.getXY().Label, "Top")
self.assertEqual(origin.getXZ().Label, "Front")
self.assertEqual(origin.getYZ().Label, "Right")
# Save to temp file
tmp = tempfile.mktemp(suffix=".FCStd")
try:
self.doc.saveAs(tmp)
# Close and reopen
doc_name = self.doc.Name
App.closeDocument(doc_name)
App.openDocument(tmp)
doc = App.ActiveDocument
assembly = doc.getObject("Assembly")
self.assertIsNotNone(assembly)
origin = assembly.Origin
self.assertEqual(origin.getXY().Label, "Top")
self.assertEqual(origin.getXZ().Label, "Front")
self.assertEqual(origin.getYZ().Label, "Right")
App.closeDocument(doc.Name)
finally:
if os.path.exists(tmp):
os.remove(tmp)
# Reopen a fresh doc for tearDown
App.newDocument(self.__class__.__name__)