feat(assembly): fixed reference planes + solver docs
Some checks failed
Build and Test / build (pull_request) Failing after 7m52s
Some checks failed
Build and Test / build (pull_request) Failing after 7m52s
Assembly Origin Planes: - AssemblyObject::setupObject() relabels origin planes to Top (XY), Front (XZ), Right (YZ) on assembly creation - CommandCreateAssembly.py makes origin planes visible by default - AssemblyUtils.cpp getObjFromRef() resolves LocalCoordinateSystem to child datum elements for joint references to origin planes - TestAssemblyOriginPlanes.py: 9 integration tests covering structure, labels, grounding, reference resolution, solver, and save/load round-trip Solver Documentation: - docs/src/solver/: 7 new pages covering architecture overview, expression DAG, constraints, solving algorithms, diagnostics, assembly integration, and writing custom solvers - docs/src/SUMMARY.md: added Kindred Solver section
This commit is contained in:
230
src/Mod/Assembly/AssemblyTests/TestAssemblyOriginPlanes.py
Normal file
230
src/Mod/Assembly/AssemblyTests/TestAssemblyOriginPlanes.py
Normal 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__)
|
||||
Reference in New Issue
Block a user