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:
@@ -30,6 +30,7 @@
|
||||
|
||||
#include <App/Application.h>
|
||||
#include <App/Datums.h>
|
||||
#include <App/Origin.h>
|
||||
#include <App/Document.h>
|
||||
#include <App/DocumentObjectGroup.h>
|
||||
#include <App/FeaturePythonPyImp.h>
|
||||
@@ -106,6 +107,24 @@ AssemblyObject::AssemblyObject()
|
||||
|
||||
AssemblyObject::~AssemblyObject() = default;
|
||||
|
||||
void AssemblyObject::setupObject()
|
||||
{
|
||||
App::Part::setupObject();
|
||||
|
||||
// Relabel origin planes with assembly-friendly names (SolidWorks convention)
|
||||
if (auto* origin = getOrigin()) {
|
||||
if (auto* xy = origin->getXY()) {
|
||||
xy->Label.setValue("Top");
|
||||
}
|
||||
if (auto* xz = origin->getXZ()) {
|
||||
xz->Label.setValue("Front");
|
||||
}
|
||||
if (auto* yz = origin->getYZ()) {
|
||||
yz->Label.setValue("Right");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PyObject* AssemblyObject::getPyObject()
|
||||
{
|
||||
if (PythonObject.is(Py::_None())) {
|
||||
@@ -144,14 +163,39 @@ void AssemblyObject::onChanged(const App::Property* prop)
|
||||
|
||||
// ── Solver integration ─────────────────────────────────────────────
|
||||
|
||||
void AssemblyObject::resetSolver()
|
||||
{
|
||||
solver_.reset();
|
||||
}
|
||||
|
||||
KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver()
|
||||
{
|
||||
if (!solver_) {
|
||||
solver_ = KCSolve::SolverRegistry::instance().get("ondsel");
|
||||
ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath(
|
||||
"User parameter:BaseApp/Preferences/Mod/Assembly");
|
||||
std::string solverName = hGrp->GetASCII("Solver", "");
|
||||
solver_ = KCSolve::SolverRegistry::instance().get(solverName);
|
||||
// get("") returns the registry default (first registered solver)
|
||||
}
|
||||
return solver_.get();
|
||||
}
|
||||
|
||||
KCSolve::SolveContext AssemblyObject::getSolveContext()
|
||||
{
|
||||
partIdToObjs_.clear();
|
||||
objToPartId_.clear();
|
||||
|
||||
auto groundedObjs = getGroundedParts();
|
||||
if (groundedObjs.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<App::DocumentObject*> joints = getJoints(false);
|
||||
removeUnconnectedJoints(joints, groundedObjs);
|
||||
|
||||
return buildSolveContext(joints);
|
||||
}
|
||||
|
||||
int AssemblyObject::solve(bool enableRedo, bool updateJCS)
|
||||
{
|
||||
ensureIdentityPlacements();
|
||||
|
||||
@@ -84,6 +84,7 @@ public:
|
||||
return "AssemblyGui::ViewProviderAssembly";
|
||||
}
|
||||
|
||||
void setupObject() override;
|
||||
App::DocumentObjectExecReturn* execute() override;
|
||||
void onChanged(const App::Property* prop) override;
|
||||
/* Solve the assembly. It will update first the joints, solve, update placements of the parts
|
||||
@@ -98,10 +99,15 @@ public:
|
||||
void postDrag();
|
||||
void savePlacementsForUndo();
|
||||
void undoSolve();
|
||||
void resetSolver();
|
||||
void clearUndo();
|
||||
|
||||
void exportAsASMT(std::string fileName);
|
||||
|
||||
/// Build the assembly constraint graph without solving.
|
||||
/// Returns an empty SolveContext if no parts are grounded.
|
||||
KCSolve::SolveContext getSolveContext();
|
||||
|
||||
bool validateNewPlacements();
|
||||
void setNewPlacements();
|
||||
static void redrawJointPlacements(std::vector<App::DocumentObject*> joints);
|
||||
|
||||
@@ -591,6 +591,19 @@ App::DocumentObject* getObjFromRef(App::DocumentObject* comp, const std::string&
|
||||
if (obj->isDerivedFrom<App::Part>() || obj->isLinkGroup()) {
|
||||
continue;
|
||||
}
|
||||
else if (obj->isDerivedFrom<App::LocalCoordinateSystem>()) {
|
||||
// Resolve LCS → child datum element (e.g. Origin → XY_Plane)
|
||||
auto nextIt = std::next(it);
|
||||
if (nextIt != names.end()) {
|
||||
for (auto* child : obj->getOutList()) {
|
||||
if (child->getNameInDocument() == *nextIt
|
||||
&& child->isDerivedFrom<App::DatumElement>()) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
else if (obj->isDerivedFrom<PartDesign::Body>()) {
|
||||
return handlePartDesignBody(obj, it);
|
||||
}
|
||||
|
||||
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__)
|
||||
@@ -58,7 +58,9 @@ SET(AssemblyTests_SRCS
|
||||
AssemblyTests/TestCore.py
|
||||
AssemblyTests/TestCommandInsertLink.py
|
||||
AssemblyTests/TestSolverIntegration.py
|
||||
AssemblyTests/TestKindredSolverIntegration.py
|
||||
AssemblyTests/TestKCSolvePy.py
|
||||
AssemblyTests/TestAssemblyOriginPlanes.py
|
||||
AssemblyTests/mocks/__init__.py
|
||||
AssemblyTests/mocks/MockGui.py
|
||||
)
|
||||
|
||||
@@ -22,15 +22,14 @@
|
||||
# **************************************************************************/
|
||||
|
||||
import FreeCAD as App
|
||||
|
||||
from PySide.QtCore import QT_TRANSLATE_NOOP
|
||||
|
||||
if App.GuiUp:
|
||||
import FreeCADGui as Gui
|
||||
from PySide import QtCore, QtGui, QtWidgets
|
||||
|
||||
import UtilsAssembly
|
||||
import Preferences
|
||||
import UtilsAssembly
|
||||
|
||||
translate = App.Qt.translate
|
||||
|
||||
@@ -78,14 +77,22 @@ class CommandCreateAssembly:
|
||||
'assembly = activeAssembly.newObject("Assembly::AssemblyObject", "Assembly")\n'
|
||||
)
|
||||
else:
|
||||
commands = (
|
||||
'assembly = App.ActiveDocument.addObject("Assembly::AssemblyObject", "Assembly")\n'
|
||||
)
|
||||
commands = 'assembly = App.ActiveDocument.addObject("Assembly::AssemblyObject", "Assembly")\n'
|
||||
|
||||
commands = commands + 'assembly.Type = "Assembly"\n'
|
||||
commands = commands + 'assembly.newObject("Assembly::JointGroup", "Joints")'
|
||||
|
||||
Gui.doCommand(commands)
|
||||
|
||||
# Make origin planes visible by default so they serve as
|
||||
# reference geometry (like SolidWorks Front/Top/Right planes).
|
||||
Gui.doCommandGui(
|
||||
"assembly.Origin.ViewObject.Visibility = True\n"
|
||||
"for feat in assembly.Origin.OriginFeatures:\n"
|
||||
" if feat.isDerivedFrom('App::Plane'):\n"
|
||||
" feat.ViewObject.Visibility = True\n"
|
||||
)
|
||||
|
||||
if not activeAssembly:
|
||||
Gui.doCommandGui("Gui.ActiveDocument.setEdit(assembly)")
|
||||
|
||||
@@ -98,7 +105,9 @@ class ActivateAssemblyTaskPanel:
|
||||
def __init__(self, assemblies):
|
||||
self.assemblies = assemblies
|
||||
self.form = QtWidgets.QWidget()
|
||||
self.form.setWindowTitle(translate("Assembly_ActivateAssembly", "Activate Assembly"))
|
||||
self.form.setWindowTitle(
|
||||
translate("Assembly_ActivateAssembly", "Activate Assembly")
|
||||
)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self.form)
|
||||
label = QtWidgets.QLabel(
|
||||
@@ -132,9 +141,12 @@ class CommandActivateAssembly:
|
||||
def GetResources(self):
|
||||
return {
|
||||
"Pixmap": "Assembly_ActivateAssembly",
|
||||
"MenuText": QT_TRANSLATE_NOOP("Assembly_ActivateAssembly", "Activate Assembly"),
|
||||
"MenuText": QT_TRANSLATE_NOOP(
|
||||
"Assembly_ActivateAssembly", "Activate Assembly"
|
||||
),
|
||||
"ToolTip": QT_TRANSLATE_NOOP(
|
||||
"Assembly_ActivateAssembly", "Sets an assembly as the active one for editing."
|
||||
"Assembly_ActivateAssembly",
|
||||
"Sets an assembly as the active one for editing.",
|
||||
),
|
||||
"CmdType": "ForEdit",
|
||||
}
|
||||
@@ -156,7 +168,9 @@ class CommandActivateAssembly:
|
||||
|
||||
def Activated(self):
|
||||
doc = App.ActiveDocument
|
||||
assemblies = [o for o in doc.Objects if o.isDerivedFrom("Assembly::AssemblyObject")]
|
||||
assemblies = [
|
||||
o for o in doc.Objects if o.isDerivedFrom("Assembly::AssemblyObject")
|
||||
]
|
||||
|
||||
if len(assemblies) == 1:
|
||||
# If there's only one, activate it directly without showing a dialog
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
# **************************************************************************/
|
||||
|
||||
import TestApp
|
||||
from AssemblyTests.TestAssemblyOriginPlanes import TestAssemblyOriginPlanes
|
||||
from AssemblyTests.TestCommandInsertLink import TestCommandInsertLink
|
||||
from AssemblyTests.TestCore import TestCore
|
||||
from AssemblyTests.TestKCSolvePy import (
|
||||
@@ -30,9 +31,12 @@ from AssemblyTests.TestKCSolvePy import (
|
||||
TestKCSolveTypes, # noqa: F401
|
||||
TestPySolver, # noqa: F401
|
||||
)
|
||||
from AssemblyTests.TestKindredSolverIntegration import TestKindredSolverIntegration
|
||||
from AssemblyTests.TestSolverIntegration import TestSolverIntegration
|
||||
|
||||
# Use the modules so that code checkers don't complain (flake8)
|
||||
True if TestCore else False
|
||||
True if TestCommandInsertLink else False
|
||||
True if TestAssemblyOriginPlanes else False
|
||||
True if TestSolverIntegration else False
|
||||
True if TestKindredSolverIntegration else False
|
||||
|
||||
Reference in New Issue
Block a user