feat(solver): KCSolve solver addon with assembly integration (#289)
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Adds the Kindred constraint solver as a pluggable Assembly workbench backend, covering phases 3d through 5 of the solver roadmap. Phase 3d: SolveContext packing - Pack/unpack SolveContext into .kc archive on document save Solver addon (mods/solver): - Phase 1: Expression DAG, Newton-Raphson + BFGS, 3 basic constraints - Phase 2: Full constraint vocabulary — all 24 BaseJointKind types - Phase 3: Graph decomposition for cluster-by-cluster solving - Phase 4: Per-entity DOF diagnostics, overconstrained detection, half-space preference tracking, minimum-movement weighting - Phase 5: _build_system extraction, diagnose(), drag protocol, joint limits warning Assembly workbench integration: - Preference-driven solver selection (reads Mod/Assembly/Solver param) - Solver backend combo box in Assembly preferences UI - resetSolver() on AssemblyObject for live preference switching - Integration tests (TestKindredSolverIntegration.py) - In-client console test script (console_test_phase5.py)
This commit is contained in:
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -18,3 +18,7 @@
|
|||||||
path = mods/silo
|
path = mods/silo
|
||||||
url = https://git.kindred-systems.com/kindred/silo-mod.git
|
url = https://git.kindred-systems.com/kindred/silo-mod.git
|
||||||
branch = main
|
branch = main
|
||||||
|
[submodule "mods/solver"]
|
||||||
|
path = mods/solver
|
||||||
|
url = https://git.kindred-systems.com/kindred/solver.git
|
||||||
|
branch = main
|
||||||
|
|||||||
1
mods/solver
Submodule
1
mods/solver
Submodule
Submodule mods/solver added at adaa0f9a69
@@ -144,14 +144,39 @@ void AssemblyObject::onChanged(const App::Property* prop)
|
|||||||
|
|
||||||
// ── Solver integration ─────────────────────────────────────────────
|
// ── Solver integration ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
void AssemblyObject::resetSolver()
|
||||||
|
{
|
||||||
|
solver_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver()
|
KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver()
|
||||||
{
|
{
|
||||||
if (!solver_) {
|
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();
|
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)
|
int AssemblyObject::solve(bool enableRedo, bool updateJCS)
|
||||||
{
|
{
|
||||||
ensureIdentityPlacements();
|
ensureIdentityPlacements();
|
||||||
|
|||||||
@@ -98,10 +98,15 @@ public:
|
|||||||
void postDrag();
|
void postDrag();
|
||||||
void savePlacementsForUndo();
|
void savePlacementsForUndo();
|
||||||
void undoSolve();
|
void undoSolve();
|
||||||
|
void resetSolver();
|
||||||
void clearUndo();
|
void clearUndo();
|
||||||
|
|
||||||
void exportAsASMT(std::string fileName);
|
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();
|
bool validateNewPlacements();
|
||||||
void setNewPlacements();
|
void setNewPlacements();
|
||||||
static void redrawJointPlacements(std::vector<App::DocumentObject*> joints);
|
static void redrawJointPlacements(std::vector<App::DocumentObject*> joints);
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Final
|
from typing import Any, Final
|
||||||
|
|
||||||
from Base.Metadata import constmethod, export
|
|
||||||
|
|
||||||
from App.Part import Part
|
|
||||||
from App.DocumentObject import DocumentObject
|
from App.DocumentObject import DocumentObject
|
||||||
|
from App.Part import Part
|
||||||
|
from Base.Metadata import constmethod, export
|
||||||
|
|
||||||
@export(Include="Mod/Assembly/App/AssemblyObject.h", Namespace="Assembly")
|
@export(Include="Mod/Assembly/App/AssemblyObject.h", Namespace="Assembly")
|
||||||
class AssemblyObject(Part):
|
class AssemblyObject(Part):
|
||||||
@@ -119,7 +118,9 @@ class AssemblyObject(Part):
|
|||||||
...
|
...
|
||||||
|
|
||||||
@constmethod
|
@constmethod
|
||||||
def isJointConnectingPartToGround(self, joint: DocumentObject, prop_name: str, /) -> Any:
|
def isJointConnectingPartToGround(
|
||||||
|
self, joint: DocumentObject, prop_name: str, /
|
||||||
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
Check if a joint is connecting a part to the ground.
|
Check if a joint is connecting a part to the ground.
|
||||||
|
|
||||||
@@ -153,6 +154,16 @@ class AssemblyObject(Part):
|
|||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
@constmethod
|
||||||
|
def getSolveContext(self) -> dict:
|
||||||
|
"""Build the assembly constraint graph as a serializable dict.
|
||||||
|
|
||||||
|
Returns a dict matching kcsolve.SolveContext.to_dict() format,
|
||||||
|
or an empty dict if the assembly has no grounded parts.
|
||||||
|
Does NOT trigger a solve.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
@constmethod
|
@constmethod
|
||||||
def getDownstreamParts(
|
def getDownstreamParts(
|
||||||
self, start_part: DocumentObject, joint_to_ignore: DocumentObject, /
|
self, start_part: DocumentObject, joint_to_ignore: DocumentObject, /
|
||||||
|
|||||||
@@ -21,13 +21,161 @@
|
|||||||
* *
|
* *
|
||||||
***************************************************************************/
|
***************************************************************************/
|
||||||
|
|
||||||
|
|
||||||
// inclusion of the generated files (generated out of AssemblyObject.xml)
|
// inclusion of the generated files (generated out of AssemblyObject.xml)
|
||||||
#include "AssemblyObjectPy.h"
|
#include "AssemblyObjectPy.h"
|
||||||
#include "AssemblyObjectPy.cpp"
|
#include "AssemblyObjectPy.cpp"
|
||||||
|
|
||||||
|
#include <Mod/Assembly/Solver/SolverRegistry.h>
|
||||||
|
|
||||||
using namespace Assembly;
|
using namespace Assembly;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// ── Enum-to-string tables for dict serialization ───────────────────
|
||||||
|
// String values must match kcsolve_py.cpp py::enum_ .value() names exactly.
|
||||||
|
|
||||||
|
const char* baseJointKindStr(KCSolve::BaseJointKind k)
|
||||||
|
{
|
||||||
|
switch (k) {
|
||||||
|
case KCSolve::BaseJointKind::Coincident: return "Coincident";
|
||||||
|
case KCSolve::BaseJointKind::PointOnLine: return "PointOnLine";
|
||||||
|
case KCSolve::BaseJointKind::PointInPlane: return "PointInPlane";
|
||||||
|
case KCSolve::BaseJointKind::Concentric: return "Concentric";
|
||||||
|
case KCSolve::BaseJointKind::Tangent: return "Tangent";
|
||||||
|
case KCSolve::BaseJointKind::Planar: return "Planar";
|
||||||
|
case KCSolve::BaseJointKind::LineInPlane: return "LineInPlane";
|
||||||
|
case KCSolve::BaseJointKind::Parallel: return "Parallel";
|
||||||
|
case KCSolve::BaseJointKind::Perpendicular: return "Perpendicular";
|
||||||
|
case KCSolve::BaseJointKind::Angle: return "Angle";
|
||||||
|
case KCSolve::BaseJointKind::Fixed: return "Fixed";
|
||||||
|
case KCSolve::BaseJointKind::Revolute: return "Revolute";
|
||||||
|
case KCSolve::BaseJointKind::Cylindrical: return "Cylindrical";
|
||||||
|
case KCSolve::BaseJointKind::Slider: return "Slider";
|
||||||
|
case KCSolve::BaseJointKind::Ball: return "Ball";
|
||||||
|
case KCSolve::BaseJointKind::Screw: return "Screw";
|
||||||
|
case KCSolve::BaseJointKind::Universal: return "Universal";
|
||||||
|
case KCSolve::BaseJointKind::Gear: return "Gear";
|
||||||
|
case KCSolve::BaseJointKind::RackPinion: return "RackPinion";
|
||||||
|
case KCSolve::BaseJointKind::Cam: return "Cam";
|
||||||
|
case KCSolve::BaseJointKind::Slot: return "Slot";
|
||||||
|
case KCSolve::BaseJointKind::DistancePointPoint: return "DistancePointPoint";
|
||||||
|
case KCSolve::BaseJointKind::DistanceCylSph: return "DistanceCylSph";
|
||||||
|
case KCSolve::BaseJointKind::Custom: return "Custom";
|
||||||
|
}
|
||||||
|
return "Custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* limitKindStr(KCSolve::Constraint::Limit::Kind k)
|
||||||
|
{
|
||||||
|
switch (k) {
|
||||||
|
case KCSolve::Constraint::Limit::Kind::TranslationMin: return "TranslationMin";
|
||||||
|
case KCSolve::Constraint::Limit::Kind::TranslationMax: return "TranslationMax";
|
||||||
|
case KCSolve::Constraint::Limit::Kind::RotationMin: return "RotationMin";
|
||||||
|
case KCSolve::Constraint::Limit::Kind::RotationMax: return "RotationMax";
|
||||||
|
}
|
||||||
|
return "TranslationMin";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* motionKindStr(KCSolve::MotionDef::Kind k)
|
||||||
|
{
|
||||||
|
switch (k) {
|
||||||
|
case KCSolve::MotionDef::Kind::Rotational: return "Rotational";
|
||||||
|
case KCSolve::MotionDef::Kind::Translational: return "Translational";
|
||||||
|
case KCSolve::MotionDef::Kind::General: return "General";
|
||||||
|
}
|
||||||
|
return "Rotational";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Python dict builders ───────────────────────────────────────────
|
||||||
|
// Layout matches solve_context_to_dict() in kcsolve_py.cpp exactly.
|
||||||
|
|
||||||
|
Py::Dict transformToDict(const KCSolve::Transform& t)
|
||||||
|
{
|
||||||
|
Py::Dict d;
|
||||||
|
d.setItem("position", Py::TupleN(
|
||||||
|
Py::Float(t.position[0]),
|
||||||
|
Py::Float(t.position[1]),
|
||||||
|
Py::Float(t.position[2])));
|
||||||
|
d.setItem("quaternion", Py::TupleN(
|
||||||
|
Py::Float(t.quaternion[0]),
|
||||||
|
Py::Float(t.quaternion[1]),
|
||||||
|
Py::Float(t.quaternion[2]),
|
||||||
|
Py::Float(t.quaternion[3])));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Py::Dict partToDict(const KCSolve::Part& p)
|
||||||
|
{
|
||||||
|
Py::Dict d;
|
||||||
|
d.setItem("id", Py::String(p.id));
|
||||||
|
d.setItem("placement", transformToDict(p.placement));
|
||||||
|
d.setItem("mass", Py::Float(p.mass));
|
||||||
|
d.setItem("grounded", Py::Boolean(p.grounded));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Py::Dict limitToDict(const KCSolve::Constraint::Limit& lim)
|
||||||
|
{
|
||||||
|
Py::Dict d;
|
||||||
|
d.setItem("kind", Py::String(limitKindStr(lim.kind)));
|
||||||
|
d.setItem("value", Py::Float(lim.value));
|
||||||
|
d.setItem("tolerance", Py::Float(lim.tolerance));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Py::Dict constraintToDict(const KCSolve::Constraint& c)
|
||||||
|
{
|
||||||
|
Py::Dict d;
|
||||||
|
d.setItem("id", Py::String(c.id));
|
||||||
|
d.setItem("part_i", Py::String(c.part_i));
|
||||||
|
d.setItem("marker_i", transformToDict(c.marker_i));
|
||||||
|
d.setItem("part_j", Py::String(c.part_j));
|
||||||
|
d.setItem("marker_j", transformToDict(c.marker_j));
|
||||||
|
d.setItem("type", Py::String(baseJointKindStr(c.type)));
|
||||||
|
|
||||||
|
Py::List params;
|
||||||
|
for (double v : c.params) {
|
||||||
|
params.append(Py::Float(v));
|
||||||
|
}
|
||||||
|
d.setItem("params", params);
|
||||||
|
|
||||||
|
Py::List lims;
|
||||||
|
for (const auto& l : c.limits) {
|
||||||
|
lims.append(limitToDict(l));
|
||||||
|
}
|
||||||
|
d.setItem("limits", lims);
|
||||||
|
d.setItem("activated", Py::Boolean(c.activated));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Py::Dict motionToDict(const KCSolve::MotionDef& m)
|
||||||
|
{
|
||||||
|
Py::Dict d;
|
||||||
|
d.setItem("kind", Py::String(motionKindStr(m.kind)));
|
||||||
|
d.setItem("joint_id", Py::String(m.joint_id));
|
||||||
|
d.setItem("marker_i", Py::String(m.marker_i));
|
||||||
|
d.setItem("marker_j", Py::String(m.marker_j));
|
||||||
|
d.setItem("rotation_expr", Py::String(m.rotation_expr));
|
||||||
|
d.setItem("translation_expr", Py::String(m.translation_expr));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Py::Dict simToDict(const KCSolve::SimulationParams& s)
|
||||||
|
{
|
||||||
|
Py::Dict d;
|
||||||
|
d.setItem("t_start", Py::Float(s.t_start));
|
||||||
|
d.setItem("t_end", Py::Float(s.t_end));
|
||||||
|
d.setItem("h_out", Py::Float(s.h_out));
|
||||||
|
d.setItem("h_min", Py::Float(s.h_min));
|
||||||
|
d.setItem("h_max", Py::Float(s.h_max));
|
||||||
|
d.setItem("error_tol", Py::Float(s.error_tol));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // anonymous namespace
|
||||||
|
|
||||||
// returns a string which represents the object e.g. when printed in python
|
// returns a string which represents the object e.g. when printed in python
|
||||||
std::string AssemblyObjectPy::representation() const
|
std::string AssemblyObjectPy::representation() const
|
||||||
{
|
{
|
||||||
@@ -243,3 +391,52 @@ PyObject* AssemblyObjectPy::getDownstreamParts(PyObject* args) const
|
|||||||
|
|
||||||
return Py::new_reference_to(ret);
|
return Py::new_reference_to(ret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PyObject* AssemblyObjectPy::getSolveContext(PyObject* args) const
|
||||||
|
{
|
||||||
|
if (!PyArg_ParseTuple(args, "")) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
PY_TRY
|
||||||
|
{
|
||||||
|
KCSolve::SolveContext ctx = getAssemblyObjectPtr()->getSolveContext();
|
||||||
|
|
||||||
|
// Empty context (no grounded parts) → return empty dict
|
||||||
|
if (ctx.parts.empty()) {
|
||||||
|
return Py::new_reference_to(Py::Dict());
|
||||||
|
}
|
||||||
|
|
||||||
|
Py::Dict d;
|
||||||
|
d.setItem("api_version", Py::Long(KCSolve::API_VERSION_MAJOR));
|
||||||
|
|
||||||
|
Py::List parts;
|
||||||
|
for (const auto& p : ctx.parts) {
|
||||||
|
parts.append(partToDict(p));
|
||||||
|
}
|
||||||
|
d.setItem("parts", parts);
|
||||||
|
|
||||||
|
Py::List constraints;
|
||||||
|
for (const auto& c : ctx.constraints) {
|
||||||
|
constraints.append(constraintToDict(c));
|
||||||
|
}
|
||||||
|
d.setItem("constraints", constraints);
|
||||||
|
|
||||||
|
Py::List motions;
|
||||||
|
for (const auto& m : ctx.motions) {
|
||||||
|
motions.append(motionToDict(m));
|
||||||
|
}
|
||||||
|
d.setItem("motions", motions);
|
||||||
|
|
||||||
|
if (ctx.simulation.has_value()) {
|
||||||
|
d.setItem("simulation", simToDict(*ctx.simulation));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
d.setItem("simulation", Py::None());
|
||||||
|
}
|
||||||
|
|
||||||
|
d.setItem("bundle_fixed", Py::Boolean(ctx.bundle_fixed));
|
||||||
|
|
||||||
|
return Py::new_reference_to(d);
|
||||||
|
}
|
||||||
|
PY_CATCH;
|
||||||
|
}
|
||||||
|
|||||||
180
src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py
Normal file
180
src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 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/>. *
|
||||||
|
# *
|
||||||
|
# ***************************************************************************/
|
||||||
|
|
||||||
|
"""
|
||||||
|
Integration tests for the Kindred solver backend.
|
||||||
|
|
||||||
|
These tests mirror TestSolverIntegration but force the solver preference
|
||||||
|
to "kindred" so the full pipeline (AssemblyObject → IKCSolver →
|
||||||
|
KindredSolver) is exercised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import FreeCAD as App
|
||||||
|
import JointObject
|
||||||
|
|
||||||
|
|
||||||
|
def _pref():
|
||||||
|
return App.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly")
|
||||||
|
|
||||||
|
|
||||||
|
class TestKindredSolverIntegration(unittest.TestCase):
|
||||||
|
"""Full-stack solver tests using the Kindred (Newton-Raphson) backend."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Force the kindred solver backend
|
||||||
|
self._prev_solver = _pref().GetString("Solver", "")
|
||||||
|
_pref().SetString("Solver", "kindred")
|
||||||
|
|
||||||
|
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")
|
||||||
|
# Reset the solver so it picks up the new preference
|
||||||
|
self.assembly.resetSolver()
|
||||||
|
self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
App.closeDocument(self.doc.Name)
|
||||||
|
_pref().SetString("Solver", self._prev_solver)
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 _ground(self, obj):
|
||||||
|
gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
|
||||||
|
JointObject.GroundedJoint(gnd, obj)
|
||||||
|
return gnd
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# ── Tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_solve_fixed_joint(self):
|
||||||
|
"""Two boxes + grounded + fixed joint -> placements match."""
|
||||||
|
box1 = self._make_box(10, 20, 30)
|
||||||
|
box2 = self._make_box(40, 50, 60)
|
||||||
|
self._ground(box2)
|
||||||
|
|
||||||
|
self._make_joint(
|
||||||
|
0,
|
||||||
|
[box2, ["Face6", "Vertex7"]],
|
||||||
|
[box1, ["Face6", "Vertex7"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
box1.Placement.isSame(box2.Placement, 1e-6),
|
||||||
|
"Fixed joint: box1 should match box2 placement",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_solve_revolute_joint(self):
|
||||||
|
"""Two boxes + grounded + revolute joint -> solve succeeds."""
|
||||||
|
box1 = self._make_box(0, 0, 0)
|
||||||
|
box2 = self._make_box(100, 0, 0)
|
||||||
|
self._ground(box1)
|
||||||
|
|
||||||
|
self._make_joint(
|
||||||
|
1,
|
||||||
|
[box1, ["Face6", "Vertex7"]],
|
||||||
|
[box2, ["Face6", "Vertex7"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.assembly.solve()
|
||||||
|
self.assertEqual(result, 0, "Revolute joint solve should succeed")
|
||||||
|
|
||||||
|
def test_solve_returns_code_for_no_ground(self):
|
||||||
|
"""Assembly with no grounded parts -> solve returns -6."""
|
||||||
|
box1 = self._make_box(0, 0, 0)
|
||||||
|
box2 = self._make_box(50, 0, 0)
|
||||||
|
|
||||||
|
joint = self.jointgroup.newObject("App::FeaturePython", "Joint")
|
||||||
|
JointObject.Joint(joint, 0)
|
||||||
|
refs = [
|
||||||
|
[box1, ["Face6", "Vertex7"]],
|
||||||
|
[box2, ["Face6", "Vertex7"]],
|
||||||
|
]
|
||||||
|
joint.Proxy.setJointConnectors(joint, refs)
|
||||||
|
|
||||||
|
result = self.assembly.solve()
|
||||||
|
self.assertEqual(result, -6, "No grounded parts should return -6")
|
||||||
|
|
||||||
|
def test_solve_dof_reporting(self):
|
||||||
|
"""Revolute joint -> DOF = 1."""
|
||||||
|
box1 = self._make_box(0, 0, 0)
|
||||||
|
box2 = self._make_box(100, 0, 0)
|
||||||
|
self._ground(box1)
|
||||||
|
|
||||||
|
self._make_joint(
|
||||||
|
1,
|
||||||
|
[box1, ["Face6", "Vertex7"]],
|
||||||
|
[box2, ["Face6", "Vertex7"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assembly.solve()
|
||||||
|
dof = self.assembly.getLastDoF()
|
||||||
|
self.assertEqual(dof, 1, "Revolute joint should leave 1 DOF")
|
||||||
|
|
||||||
|
def test_solve_stability(self):
|
||||||
|
"""Solving twice produces identical placements."""
|
||||||
|
box1 = self._make_box(10, 20, 30)
|
||||||
|
box2 = self._make_box(40, 50, 60)
|
||||||
|
self._ground(box2)
|
||||||
|
|
||||||
|
self._make_joint(
|
||||||
|
0,
|
||||||
|
[box2, ["Face6", "Vertex7"]],
|
||||||
|
[box1, ["Face6", "Vertex7"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assembly.solve()
|
||||||
|
plc_first = App.Placement(box1.Placement)
|
||||||
|
|
||||||
|
self.assembly.solve()
|
||||||
|
plc_second = box1.Placement
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
plc_first.isSame(plc_second, 1e-6),
|
||||||
|
"Deterministic solver should produce identical results",
|
||||||
|
)
|
||||||
@@ -58,6 +58,7 @@ SET(AssemblyTests_SRCS
|
|||||||
AssemblyTests/TestCore.py
|
AssemblyTests/TestCore.py
|
||||||
AssemblyTests/TestCommandInsertLink.py
|
AssemblyTests/TestCommandInsertLink.py
|
||||||
AssemblyTests/TestSolverIntegration.py
|
AssemblyTests/TestSolverIntegration.py
|
||||||
|
AssemblyTests/TestKindredSolverIntegration.py
|
||||||
AssemblyTests/TestKCSolvePy.py
|
AssemblyTests/TestKCSolvePy.py
|
||||||
AssemblyTests/mocks/__init__.py
|
AssemblyTests/mocks/__init__.py
|
||||||
AssemblyTests/mocks/MockGui.py
|
AssemblyTests/mocks/MockGui.py
|
||||||
|
|||||||
@@ -84,6 +84,20 @@ The files are named "runPreDrag.asmt" and "dragging.log" and are located in the
|
|||||||
</spacer>
|
</spacer>
|
||||||
</item>
|
</item>
|
||||||
<item row="3" column="0">
|
<item row="3" column="0">
|
||||||
|
<widget class="QLabel" name="solverBackendLabel">
|
||||||
|
<property name="text">
|
||||||
|
<string>Solver backend</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="3" column="1">
|
||||||
|
<widget class="QComboBox" name="solverBackend">
|
||||||
|
<property name="toolTip">
|
||||||
|
<string>Select the constraint solver used for assembly solving</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="4" column="0">
|
||||||
<spacer name="verticalSpacer">
|
<spacer name="verticalSpacer">
|
||||||
<property name="orientation">
|
<property name="orientation">
|
||||||
<enum>Qt::Vertical</enum>
|
<enum>Qt::Vertical</enum>
|
||||||
|
|||||||
@@ -40,13 +40,34 @@ class PreferencesPage:
|
|||||||
pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked())
|
pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked())
|
||||||
pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked())
|
pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked())
|
||||||
pref.SetInt("GroundFirstPart", self.form.groundFirstPart.currentIndex())
|
pref.SetInt("GroundFirstPart", self.form.groundFirstPart.currentIndex())
|
||||||
|
idx = self.form.solverBackend.currentIndex()
|
||||||
|
solver_name = self.form.solverBackend.itemData(idx) or ""
|
||||||
|
pref.SetString("Solver", solver_name)
|
||||||
|
|
||||||
def loadSettings(self):
|
def loadSettings(self):
|
||||||
pref = preferences()
|
pref = preferences()
|
||||||
self.form.checkBoxEnableEscape.setChecked(pref.GetBool("LeaveEditWithEscape", True))
|
self.form.checkBoxEnableEscape.setChecked(
|
||||||
|
pref.GetBool("LeaveEditWithEscape", True)
|
||||||
|
)
|
||||||
self.form.checkBoxSolverDebug.setChecked(pref.GetBool("LogSolverDebug", False))
|
self.form.checkBoxSolverDebug.setChecked(pref.GetBool("LogSolverDebug", False))
|
||||||
self.form.groundFirstPart.clear()
|
self.form.groundFirstPart.clear()
|
||||||
self.form.groundFirstPart.addItem(translate("Assembly", "Ask"))
|
self.form.groundFirstPart.addItem(translate("Assembly", "Ask"))
|
||||||
self.form.groundFirstPart.addItem(translate("Assembly", "Always"))
|
self.form.groundFirstPart.addItem(translate("Assembly", "Always"))
|
||||||
self.form.groundFirstPart.addItem(translate("Assembly", "Never"))
|
self.form.groundFirstPart.addItem(translate("Assembly", "Never"))
|
||||||
self.form.groundFirstPart.setCurrentIndex(pref.GetInt("GroundFirstPart", 0))
|
self.form.groundFirstPart.setCurrentIndex(pref.GetInt("GroundFirstPart", 0))
|
||||||
|
|
||||||
|
self.form.solverBackend.clear()
|
||||||
|
self.form.solverBackend.addItem(translate("Assembly", "Default"), "")
|
||||||
|
try:
|
||||||
|
import kcsolve
|
||||||
|
|
||||||
|
for name in kcsolve.available():
|
||||||
|
solver = kcsolve.load(name)
|
||||||
|
self.form.solverBackend.addItem(solver.name(), name)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
current = pref.GetString("Solver", "")
|
||||||
|
for i in range(self.form.solverBackend.count()):
|
||||||
|
if self.form.solverBackend.itemData(i) == current:
|
||||||
|
self.form.solverBackend.setCurrentIndex(i)
|
||||||
|
break
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ from AssemblyTests.TestKCSolvePy import (
|
|||||||
TestKCSolveTypes, # noqa: F401
|
TestKCSolveTypes, # noqa: F401
|
||||||
TestPySolver, # noqa: F401
|
TestPySolver, # noqa: F401
|
||||||
)
|
)
|
||||||
|
from AssemblyTests.TestKindredSolverIntegration import TestKindredSolverIntegration
|
||||||
from AssemblyTests.TestSolverIntegration import TestSolverIntegration
|
from AssemblyTests.TestSolverIntegration import TestSolverIntegration
|
||||||
|
|
||||||
# Use the modules so that code checkers don't complain (flake8)
|
# Use the modules so that code checkers don't complain (flake8)
|
||||||
True if TestCore else False
|
True if TestCore else False
|
||||||
True if TestCommandInsertLink else False
|
True if TestCommandInsertLink else False
|
||||||
True if TestSolverIntegration else False
|
True if TestSolverIntegration else False
|
||||||
|
True if TestKindredSolverIntegration else False
|
||||||
|
|||||||
@@ -84,3 +84,18 @@ install(
|
|||||||
DESTINATION
|
DESTINATION
|
||||||
mods/sdk
|
mods/sdk
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Install Kindred Solver addon
|
||||||
|
install(
|
||||||
|
DIRECTORY
|
||||||
|
${CMAKE_SOURCE_DIR}/mods/solver/kindred_solver
|
||||||
|
DESTINATION
|
||||||
|
mods/solver
|
||||||
|
)
|
||||||
|
install(
|
||||||
|
FILES
|
||||||
|
${CMAKE_SOURCE_DIR}/mods/solver/package.xml
|
||||||
|
${CMAKE_SOURCE_DIR}/mods/solver/Init.py
|
||||||
|
DESTINATION
|
||||||
|
mods/solver
|
||||||
|
)
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ def _manifest_enrich_hook(doc, filename, entries):
|
|||||||
register_pre_reinject(_manifest_enrich_hook)
|
register_pre_reinject(_manifest_enrich_hook)
|
||||||
|
|
||||||
|
|
||||||
|
def _solver_context_hook(doc, filename, entries):
|
||||||
|
"""Pack solver context into silo/solver/context.json for assemblies."""
|
||||||
|
try:
|
||||||
|
for obj in doc.Objects:
|
||||||
|
if obj.TypeId == "Assembly::AssemblyObject":
|
||||||
|
ctx = obj.getSolveContext()
|
||||||
|
if ctx: # non-empty means we have grounded parts
|
||||||
|
entries["silo/solver/context.json"] = (
|
||||||
|
json.dumps(ctx, indent=2) + "\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
break # one assembly per document
|
||||||
|
except Exception as exc:
|
||||||
|
FreeCAD.Console.PrintWarning(f"kc_format: solver context hook failed: {exc}\n")
|
||||||
|
|
||||||
|
|
||||||
|
register_pre_reinject(_solver_context_hook)
|
||||||
|
|
||||||
|
|
||||||
KC_VERSION = "1.0"
|
KC_VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ _KNOWN_ENTRIES = [
|
|||||||
"Dependencies",
|
"Dependencies",
|
||||||
("links", lambda v: isinstance(v, list) and len(v) > 0),
|
("links", lambda v: isinstance(v, list) and len(v) > 0),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"silo/solver/context.json",
|
||||||
|
"SiloSolverContext",
|
||||||
|
"Solver Context",
|
||||||
|
("parts", lambda v: isinstance(v, list) and len(v) > 0),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user