feat(assembly): fixed reference planes + solver docs
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:
forbes
2026-02-21 09:09:16 -06:00
parent 311b3ea4f1
commit acc255972d
15 changed files with 1303 additions and 10 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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);
}

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__)

View File

@@ -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
)

View File

@@ -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

View File

@@ -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