Compare commits
12 Commits
feat/solve
...
feat/solve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b04a48a86 | ||
| 311b3ea4f1 | |||
|
|
686d8699c9 | ||
| 7dc7aac935 | |||
| 84f83a9d18 | |||
|
|
64fbc167f7 | ||
|
|
315ac2a25d | ||
|
|
98b0f72352 | ||
|
|
c0ee4ecccf | ||
| 1ed73e3eb0 | |||
|
|
7e766a228e | ||
| b02bcbfe46 |
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -18,3 +18,7 @@
|
||||
path = mods/silo
|
||||
url = https://git.kindred-systems.com/kindred/silo-mod.git
|
||||
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
@@ -267,7 +267,8 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
QStringLiteral("Part Design Modeling Features"),
|
||||
QStringLiteral("Part Design Dress-Up Features"),
|
||||
QStringLiteral("Part Design Transformation Features")},
|
||||
QStringLiteral("Part Design Transformation Features"),
|
||||
QStringLiteral("Sketcher")},
|
||||
/*.priority =*/40,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -292,7 +293,11 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Part Design Helper Features"), QStringLiteral("Sketcher")},
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
QStringLiteral("Part Design Modeling Features"),
|
||||
QStringLiteral("Part Design Dress-Up Features"),
|
||||
QStringLiteral("Part Design Transformation Features"),
|
||||
QStringLiteral("Sketcher")},
|
||||
/*.priority =*/30,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -307,7 +312,9 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Assembly: {name}"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Assembly")},
|
||||
{QStringLiteral("Assembly"),
|
||||
QStringLiteral("Assembly Joints"),
|
||||
QStringLiteral("Assembly Management")},
|
||||
/*.priority =*/30,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -340,7 +347,11 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Part Design"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Part Design Helper Features"), QStringLiteral("Sketcher")},
|
||||
{QStringLiteral("Part Design Helper Features"),
|
||||
QStringLiteral("Part Design Modeling Features"),
|
||||
QStringLiteral("Part Design Dress-Up Features"),
|
||||
QStringLiteral("Part Design Transformation Features"),
|
||||
QStringLiteral("Sketcher")},
|
||||
/*.priority =*/20,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -353,7 +364,12 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Sketcher"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Green),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Sketcher"), QStringLiteral("Sketcher Tools")},
|
||||
{QStringLiteral("Sketcher"),
|
||||
QStringLiteral("Sketcher Tools"),
|
||||
QStringLiteral("Geometries"),
|
||||
QStringLiteral("Constraints"),
|
||||
QStringLiteral("B-Spline Tools"),
|
||||
QStringLiteral("Visual Helpers")},
|
||||
/*.priority =*/20,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
@@ -366,7 +382,9 @@ void EditingContextResolver::registerBuiltinContexts()
|
||||
/*.labelTemplate =*/QStringLiteral("Assembly"),
|
||||
/*.color =*/QLatin1String(CatppuccinMocha::Blue),
|
||||
/*.toolbars =*/
|
||||
{QStringLiteral("Assembly")},
|
||||
{QStringLiteral("Assembly"),
|
||||
QStringLiteral("Assembly Joints"),
|
||||
QStringLiteral("Assembly Management")},
|
||||
/*.priority =*/20,
|
||||
/*.match =*/
|
||||
[]() {
|
||||
|
||||
@@ -144,14 +144,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();
|
||||
|
||||
@@ -98,10 +98,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);
|
||||
|
||||
@@ -4,10 +4,9 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Final
|
||||
|
||||
from Base.Metadata import constmethod, export
|
||||
|
||||
from App.Part import Part
|
||||
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")
|
||||
class AssemblyObject(Part):
|
||||
@@ -119,7 +118,9 @@ class AssemblyObject(Part):
|
||||
...
|
||||
|
||||
@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.
|
||||
|
||||
@@ -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
|
||||
def getDownstreamParts(
|
||||
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.cpp"
|
||||
|
||||
#include <Mod/Assembly/Solver/SolverRegistry.h>
|
||||
|
||||
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
|
||||
std::string AssemblyObjectPy::representation() const
|
||||
{
|
||||
@@ -243,3 +391,52 @@ PyObject* AssemblyObjectPy::getDownstreamParts(PyObject* args) const
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -149,6 +149,289 @@ class TestKCSolveRegistry(unittest.TestCase):
|
||||
self.assertEqual(kcsolve.get_default(), original)
|
||||
|
||||
|
||||
class TestKCSolveSerialization(unittest.TestCase):
|
||||
"""Verify to_dict() / from_dict() round-trip on all KCSolve types."""
|
||||
|
||||
def test_transform_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
t = kcsolve.Transform()
|
||||
t.position = [1.0, 2.0, 3.0]
|
||||
t.quaternion = [0.5, 0.5, 0.5, 0.5]
|
||||
d = t.to_dict()
|
||||
self.assertEqual(list(d["position"]), [1.0, 2.0, 3.0])
|
||||
self.assertEqual(list(d["quaternion"]), [0.5, 0.5, 0.5, 0.5])
|
||||
t2 = kcsolve.Transform.from_dict(d)
|
||||
self.assertEqual(list(t2.position), [1.0, 2.0, 3.0])
|
||||
self.assertEqual(list(t2.quaternion), [0.5, 0.5, 0.5, 0.5])
|
||||
|
||||
def test_transform_identity_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
t = kcsolve.Transform.identity()
|
||||
t2 = kcsolve.Transform.from_dict(t.to_dict())
|
||||
self.assertEqual(list(t2.position), [0.0, 0.0, 0.0])
|
||||
self.assertEqual(list(t2.quaternion), [1.0, 0.0, 0.0, 0.0])
|
||||
|
||||
def test_part_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
p = kcsolve.Part()
|
||||
p.id = "box"
|
||||
p.mass = 2.5
|
||||
p.grounded = True
|
||||
p.placement = kcsolve.Transform.identity()
|
||||
d = p.to_dict()
|
||||
self.assertEqual(d["id"], "box")
|
||||
self.assertAlmostEqual(d["mass"], 2.5)
|
||||
self.assertTrue(d["grounded"])
|
||||
p2 = kcsolve.Part.from_dict(d)
|
||||
self.assertEqual(p2.id, "box")
|
||||
self.assertAlmostEqual(p2.mass, 2.5)
|
||||
self.assertTrue(p2.grounded)
|
||||
|
||||
def test_constraint_with_limits_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "Joint001"
|
||||
c.part_i = "part1"
|
||||
c.part_j = "part2"
|
||||
c.type = kcsolve.BaseJointKind.Revolute
|
||||
c.params = [1.5, 2.5]
|
||||
lim = kcsolve.Constraint.Limit()
|
||||
lim.kind = kcsolve.LimitKind.RotationMin
|
||||
lim.value = -3.14
|
||||
lim.tolerance = 0.01
|
||||
c.limits = [lim]
|
||||
d = c.to_dict()
|
||||
self.assertEqual(d["type"], "Revolute")
|
||||
self.assertEqual(len(d["limits"]), 1)
|
||||
self.assertEqual(d["limits"][0]["kind"], "RotationMin")
|
||||
c2 = kcsolve.Constraint.from_dict(d)
|
||||
self.assertEqual(c2.type, kcsolve.BaseJointKind.Revolute)
|
||||
self.assertEqual(len(c2.limits), 1)
|
||||
self.assertEqual(c2.limits[0].kind, kcsolve.LimitKind.RotationMin)
|
||||
self.assertAlmostEqual(c2.limits[0].value, -3.14)
|
||||
|
||||
def test_solve_context_full_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
p = kcsolve.Part()
|
||||
p.id = "box"
|
||||
p.grounded = True
|
||||
ctx.parts = [p]
|
||||
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "J1"
|
||||
c.part_i = "box"
|
||||
c.part_j = "cyl"
|
||||
c.type = kcsolve.BaseJointKind.Fixed
|
||||
ctx.constraints = [c]
|
||||
ctx.bundle_fixed = True
|
||||
|
||||
d = ctx.to_dict()
|
||||
self.assertEqual(d["api_version"], kcsolve.API_VERSION_MAJOR)
|
||||
self.assertEqual(len(d["parts"]), 1)
|
||||
self.assertEqual(len(d["constraints"]), 1)
|
||||
self.assertTrue(d["bundle_fixed"])
|
||||
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d)
|
||||
self.assertEqual(ctx2.parts[0].id, "box")
|
||||
self.assertTrue(ctx2.parts[0].grounded)
|
||||
self.assertEqual(ctx2.constraints[0].type, kcsolve.BaseJointKind.Fixed)
|
||||
self.assertTrue(ctx2.bundle_fixed)
|
||||
|
||||
def test_solve_context_with_simulation(self):
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
ctx.parts = []
|
||||
ctx.constraints = []
|
||||
sim = kcsolve.SimulationParams()
|
||||
sim.t_start = 0.0
|
||||
sim.t_end = 10.0
|
||||
sim.h_out = 0.01
|
||||
ctx.simulation = sim
|
||||
d = ctx.to_dict()
|
||||
self.assertIsNotNone(d["simulation"])
|
||||
self.assertAlmostEqual(d["simulation"]["t_end"], 10.0)
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d)
|
||||
self.assertIsNotNone(ctx2.simulation)
|
||||
self.assertAlmostEqual(ctx2.simulation.t_end, 10.0)
|
||||
|
||||
def test_solve_context_simulation_null(self):
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
ctx.parts = []
|
||||
ctx.constraints = []
|
||||
ctx.simulation = None
|
||||
d = ctx.to_dict()
|
||||
self.assertIsNone(d["simulation"])
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d)
|
||||
self.assertIsNone(ctx2.simulation)
|
||||
|
||||
def test_solve_result_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
r = kcsolve.SolveResult()
|
||||
r.status = kcsolve.SolveStatus.Success
|
||||
r.dof = 6
|
||||
pr = kcsolve.SolveResult.PartResult()
|
||||
pr.id = "box"
|
||||
pr.placement = kcsolve.Transform.identity()
|
||||
r.placements = [pr]
|
||||
diag = kcsolve.ConstraintDiagnostic()
|
||||
diag.constraint_id = "J1"
|
||||
diag.kind = kcsolve.DiagnosticKind.Redundant
|
||||
diag.detail = "over-constrained"
|
||||
r.diagnostics = [diag]
|
||||
r.num_frames = 100
|
||||
|
||||
d = r.to_dict()
|
||||
self.assertEqual(d["status"], "Success")
|
||||
self.assertEqual(d["dof"], 6)
|
||||
self.assertEqual(d["num_frames"], 100)
|
||||
self.assertEqual(len(d["placements"]), 1)
|
||||
self.assertEqual(len(d["diagnostics"]), 1)
|
||||
|
||||
r2 = kcsolve.SolveResult.from_dict(d)
|
||||
self.assertEqual(r2.status, kcsolve.SolveStatus.Success)
|
||||
self.assertEqual(r2.dof, 6)
|
||||
self.assertEqual(r2.num_frames, 100)
|
||||
self.assertEqual(r2.placements[0].id, "box")
|
||||
self.assertEqual(r2.diagnostics[0].kind, kcsolve.DiagnosticKind.Redundant)
|
||||
|
||||
def test_motion_def_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
m = kcsolve.MotionDef()
|
||||
m.kind = kcsolve.MotionKind.Rotational
|
||||
m.joint_id = "J1"
|
||||
m.marker_i = "part1"
|
||||
m.marker_j = "part2"
|
||||
m.rotation_expr = "2*pi*time"
|
||||
m.translation_expr = ""
|
||||
d = m.to_dict()
|
||||
self.assertEqual(d["kind"], "Rotational")
|
||||
self.assertEqual(d["joint_id"], "J1")
|
||||
m2 = kcsolve.MotionDef.from_dict(d)
|
||||
self.assertEqual(m2.kind, kcsolve.MotionKind.Rotational)
|
||||
self.assertEqual(m2.rotation_expr, "2*pi*time")
|
||||
|
||||
def test_all_base_joint_kinds_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
all_kinds = [
|
||||
"Coincident",
|
||||
"PointOnLine",
|
||||
"PointInPlane",
|
||||
"Concentric",
|
||||
"Tangent",
|
||||
"Planar",
|
||||
"LineInPlane",
|
||||
"Parallel",
|
||||
"Perpendicular",
|
||||
"Angle",
|
||||
"Fixed",
|
||||
"Revolute",
|
||||
"Cylindrical",
|
||||
"Slider",
|
||||
"Ball",
|
||||
"Screw",
|
||||
"Universal",
|
||||
"Gear",
|
||||
"RackPinion",
|
||||
"Cam",
|
||||
"Slot",
|
||||
"DistancePointPoint",
|
||||
"DistanceCylSph",
|
||||
"Custom",
|
||||
]
|
||||
for name in all_kinds:
|
||||
c = kcsolve.Constraint()
|
||||
c.id = "test"
|
||||
c.part_i = "a"
|
||||
c.part_j = "b"
|
||||
c.type = getattr(kcsolve.BaseJointKind, name)
|
||||
d = c.to_dict()
|
||||
self.assertEqual(d["type"], name)
|
||||
c2 = kcsolve.Constraint.from_dict(d)
|
||||
self.assertEqual(c2.type, getattr(kcsolve.BaseJointKind, name))
|
||||
|
||||
def test_all_solve_statuses_round_trip(self):
|
||||
import kcsolve
|
||||
|
||||
for name in ("Success", "Failed", "InvalidFlip", "NoGroundedParts"):
|
||||
r = kcsolve.SolveResult()
|
||||
r.status = getattr(kcsolve.SolveStatus, name)
|
||||
d = r.to_dict()
|
||||
self.assertEqual(d["status"], name)
|
||||
r2 = kcsolve.SolveResult.from_dict(d)
|
||||
self.assertEqual(r2.status, getattr(kcsolve.SolveStatus, name))
|
||||
|
||||
def test_json_stdlib_round_trip(self):
|
||||
import json
|
||||
|
||||
import kcsolve
|
||||
|
||||
ctx = kcsolve.SolveContext()
|
||||
p = kcsolve.Part()
|
||||
p.id = "box"
|
||||
p.grounded = True
|
||||
ctx.parts = [p]
|
||||
ctx.constraints = []
|
||||
d = ctx.to_dict()
|
||||
json_str = json.dumps(d)
|
||||
d2 = json.loads(json_str)
|
||||
ctx2 = kcsolve.SolveContext.from_dict(d2)
|
||||
self.assertEqual(ctx2.parts[0].id, "box")
|
||||
|
||||
def test_from_dict_missing_required_key(self):
|
||||
import kcsolve
|
||||
|
||||
with self.assertRaises(KeyError):
|
||||
kcsolve.Part.from_dict({"mass": 1.0, "grounded": False})
|
||||
|
||||
def test_from_dict_invalid_enum_string(self):
|
||||
import kcsolve
|
||||
|
||||
d = {
|
||||
"id": "J1",
|
||||
"part_i": "a",
|
||||
"part_j": "b",
|
||||
"type": "Bogus",
|
||||
"marker_i": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]},
|
||||
"marker_j": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]},
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
kcsolve.Constraint.from_dict(d)
|
||||
|
||||
def test_from_dict_bad_position_length(self):
|
||||
import kcsolve
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
kcsolve.Transform.from_dict(
|
||||
{
|
||||
"position": [1.0, 2.0],
|
||||
"quaternion": [1, 0, 0, 0],
|
||||
}
|
||||
)
|
||||
|
||||
def test_from_dict_bad_api_version(self):
|
||||
import kcsolve
|
||||
|
||||
d = {
|
||||
"api_version": 99,
|
||||
"parts": [],
|
||||
"constraints": [],
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
kcsolve.SolveContext.from_dict(d)
|
||||
|
||||
|
||||
class TestPySolver(unittest.TestCase):
|
||||
"""Verify Python IKCSolver subclassing and registration."""
|
||||
|
||||
|
||||
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/TestCommandInsertLink.py
|
||||
AssemblyTests/TestSolverIntegration.py
|
||||
AssemblyTests/TestKindredSolverIntegration.py
|
||||
AssemblyTests/TestKCSolvePy.py
|
||||
AssemblyTests/mocks/__init__.py
|
||||
AssemblyTests/mocks/MockGui.py
|
||||
|
||||
@@ -84,6 +84,20 @@ The files are named "runPreDrag.asmt" and "dragging.log" and are located in the
|
||||
</spacer>
|
||||
</item>
|
||||
<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">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
|
||||
@@ -40,13 +40,34 @@ class PreferencesPage:
|
||||
pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked())
|
||||
pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked())
|
||||
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):
|
||||
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.groundFirstPart.clear()
|
||||
self.form.groundFirstPart.addItem(translate("Assembly", "Ask"))
|
||||
self.form.groundFirstPart.addItem(translate("Assembly", "Always"))
|
||||
self.form.groundFirstPart.addItem(translate("Assembly", "Never"))
|
||||
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
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
#include "PyIKCSolver.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -38,6 +39,456 @@ namespace py = pybind11;
|
||||
using namespace KCSolve;
|
||||
|
||||
|
||||
// ── Enum string mapping ────────────────────────────────────────────
|
||||
//
|
||||
// Constexpr tables for bidirectional enum <-> string conversion.
|
||||
// String values match the py::enum_ .value("Name", ...) names exactly,
|
||||
// which is also the JSON wire format specified in SOLVER.md §3.
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
template<typename E>
|
||||
struct EnumEntry
|
||||
{
|
||||
E value;
|
||||
const char* name;
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<BaseJointKind> kBaseJointKindEntries[] = {
|
||||
{BaseJointKind::Coincident, "Coincident"},
|
||||
{BaseJointKind::PointOnLine, "PointOnLine"},
|
||||
{BaseJointKind::PointInPlane, "PointInPlane"},
|
||||
{BaseJointKind::Concentric, "Concentric"},
|
||||
{BaseJointKind::Tangent, "Tangent"},
|
||||
{BaseJointKind::Planar, "Planar"},
|
||||
{BaseJointKind::LineInPlane, "LineInPlane"},
|
||||
{BaseJointKind::Parallel, "Parallel"},
|
||||
{BaseJointKind::Perpendicular, "Perpendicular"},
|
||||
{BaseJointKind::Angle, "Angle"},
|
||||
{BaseJointKind::Fixed, "Fixed"},
|
||||
{BaseJointKind::Revolute, "Revolute"},
|
||||
{BaseJointKind::Cylindrical, "Cylindrical"},
|
||||
{BaseJointKind::Slider, "Slider"},
|
||||
{BaseJointKind::Ball, "Ball"},
|
||||
{BaseJointKind::Screw, "Screw"},
|
||||
{BaseJointKind::Universal, "Universal"},
|
||||
{BaseJointKind::Gear, "Gear"},
|
||||
{BaseJointKind::RackPinion, "RackPinion"},
|
||||
{BaseJointKind::Cam, "Cam"},
|
||||
{BaseJointKind::Slot, "Slot"},
|
||||
{BaseJointKind::DistancePointPoint, "DistancePointPoint"},
|
||||
{BaseJointKind::DistanceCylSph, "DistanceCylSph"},
|
||||
{BaseJointKind::Custom, "Custom"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<SolveStatus> kSolveStatusEntries[] = {
|
||||
{SolveStatus::Success, "Success"},
|
||||
{SolveStatus::Failed, "Failed"},
|
||||
{SolveStatus::InvalidFlip, "InvalidFlip"},
|
||||
{SolveStatus::NoGroundedParts, "NoGroundedParts"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<ConstraintDiagnostic::Kind> kDiagnosticKindEntries[] = {
|
||||
{ConstraintDiagnostic::Kind::Redundant, "Redundant"},
|
||||
{ConstraintDiagnostic::Kind::Conflicting, "Conflicting"},
|
||||
{ConstraintDiagnostic::Kind::PartiallyRedundant, "PartiallyRedundant"},
|
||||
{ConstraintDiagnostic::Kind::Malformed, "Malformed"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<MotionDef::Kind> kMotionKindEntries[] = {
|
||||
{MotionDef::Kind::Rotational, "Rotational"},
|
||||
{MotionDef::Kind::Translational, "Translational"},
|
||||
{MotionDef::Kind::General, "General"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<Constraint::Limit::Kind> kLimitKindEntries[] = {
|
||||
{Constraint::Limit::Kind::TranslationMin, "TranslationMin"},
|
||||
{Constraint::Limit::Kind::TranslationMax, "TranslationMax"},
|
||||
{Constraint::Limit::Kind::RotationMin, "RotationMin"},
|
||||
{Constraint::Limit::Kind::RotationMax, "RotationMax"},
|
||||
};
|
||||
|
||||
template<typename E, std::size_t N>
|
||||
const char* enum_to_str(E val, const EnumEntry<E> (&table)[N])
|
||||
{
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
if (table[i].value == val) {
|
||||
return table[i].name;
|
||||
}
|
||||
}
|
||||
throw py::value_error("Unknown enum value: " + std::to_string(static_cast<int>(val)));
|
||||
}
|
||||
|
||||
template<typename E, std::size_t N>
|
||||
E str_to_enum(const std::string& name, const EnumEntry<E> (&table)[N],
|
||||
const char* enum_type_name)
|
||||
{
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
if (name == table[i].name) {
|
||||
return table[i].value;
|
||||
}
|
||||
}
|
||||
throw py::value_error(
|
||||
std::string("Invalid ") + enum_type_name + " value: '" + name + "'");
|
||||
}
|
||||
|
||||
|
||||
// ── Dict conversion helpers ────────────────────────────────────────
|
||||
//
|
||||
// Standalone functions for each type so SolveContext/SolveResult can
|
||||
// reuse them without duplicating serialization logic.
|
||||
|
||||
py::dict transform_to_dict(const Transform& t)
|
||||
{
|
||||
py::dict d;
|
||||
d["position"] = py::make_tuple(t.position[0], t.position[1], t.position[2]);
|
||||
d["quaternion"] = py::make_tuple(
|
||||
t.quaternion[0], t.quaternion[1], t.quaternion[2], t.quaternion[3]);
|
||||
return d;
|
||||
}
|
||||
|
||||
Transform transform_from_dict(const py::dict& d)
|
||||
{
|
||||
Transform t;
|
||||
auto pos = d["position"].cast<py::sequence>();
|
||||
if (py::len(pos) != 3) {
|
||||
throw py::value_error("position must have exactly 3 elements");
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
t.position[static_cast<std::size_t>(i)] = pos[i].cast<double>();
|
||||
}
|
||||
auto quat = d["quaternion"].cast<py::sequence>();
|
||||
if (py::len(quat) != 4) {
|
||||
throw py::value_error("quaternion must have exactly 4 elements");
|
||||
}
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
t.quaternion[static_cast<std::size_t>(i)] = quat[i].cast<double>();
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
py::dict part_to_dict(const Part& p)
|
||||
{
|
||||
py::dict d;
|
||||
d["id"] = p.id;
|
||||
d["placement"] = transform_to_dict(p.placement);
|
||||
d["mass"] = p.mass;
|
||||
d["grounded"] = p.grounded;
|
||||
return d;
|
||||
}
|
||||
|
||||
Part part_from_dict(const py::dict& d)
|
||||
{
|
||||
Part p;
|
||||
p.id = d["id"].cast<std::string>();
|
||||
p.placement = transform_from_dict(d["placement"].cast<py::dict>());
|
||||
if (d.contains("mass")) {
|
||||
p.mass = d["mass"].cast<double>();
|
||||
}
|
||||
if (d.contains("grounded")) {
|
||||
p.grounded = d["grounded"].cast<bool>();
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
py::dict limit_to_dict(const Constraint::Limit& lim)
|
||||
{
|
||||
py::dict d;
|
||||
d["kind"] = enum_to_str(lim.kind, kLimitKindEntries);
|
||||
d["value"] = lim.value;
|
||||
d["tolerance"] = lim.tolerance;
|
||||
return d;
|
||||
}
|
||||
|
||||
Constraint::Limit limit_from_dict(const py::dict& d)
|
||||
{
|
||||
Constraint::Limit lim;
|
||||
lim.kind = str_to_enum(d["kind"].cast<std::string>(),
|
||||
kLimitKindEntries, "LimitKind");
|
||||
lim.value = d["value"].cast<double>();
|
||||
if (d.contains("tolerance")) {
|
||||
lim.tolerance = d["tolerance"].cast<double>();
|
||||
}
|
||||
return lim;
|
||||
}
|
||||
|
||||
py::dict constraint_to_dict(const Constraint& c)
|
||||
{
|
||||
py::dict d;
|
||||
d["id"] = c.id;
|
||||
d["part_i"] = c.part_i;
|
||||
d["marker_i"] = transform_to_dict(c.marker_i);
|
||||
d["part_j"] = c.part_j;
|
||||
d["marker_j"] = transform_to_dict(c.marker_j);
|
||||
d["type"] = enum_to_str(c.type, kBaseJointKindEntries);
|
||||
d["params"] = py::cast(c.params);
|
||||
py::list lims;
|
||||
for (const auto& lim : c.limits) {
|
||||
lims.append(limit_to_dict(lim));
|
||||
}
|
||||
d["limits"] = lims;
|
||||
d["activated"] = c.activated;
|
||||
return d;
|
||||
}
|
||||
|
||||
Constraint constraint_from_dict(const py::dict& d)
|
||||
{
|
||||
Constraint c;
|
||||
c.id = d["id"].cast<std::string>();
|
||||
c.part_i = d["part_i"].cast<std::string>();
|
||||
c.marker_i = transform_from_dict(d["marker_i"].cast<py::dict>());
|
||||
c.part_j = d["part_j"].cast<std::string>();
|
||||
c.marker_j = transform_from_dict(d["marker_j"].cast<py::dict>());
|
||||
c.type = str_to_enum(d["type"].cast<std::string>(),
|
||||
kBaseJointKindEntries, "BaseJointKind");
|
||||
if (d.contains("params")) {
|
||||
c.params = d["params"].cast<std::vector<double>>();
|
||||
}
|
||||
if (d.contains("limits")) {
|
||||
for (auto item : d["limits"]) {
|
||||
c.limits.push_back(limit_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
if (d.contains("activated")) {
|
||||
c.activated = d["activated"].cast<bool>();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
py::dict motion_to_dict(const MotionDef& m)
|
||||
{
|
||||
py::dict d;
|
||||
d["kind"] = enum_to_str(m.kind, kMotionKindEntries);
|
||||
d["joint_id"] = m.joint_id;
|
||||
d["marker_i"] = m.marker_i;
|
||||
d["marker_j"] = m.marker_j;
|
||||
d["rotation_expr"] = m.rotation_expr;
|
||||
d["translation_expr"] = m.translation_expr;
|
||||
return d;
|
||||
}
|
||||
|
||||
MotionDef motion_from_dict(const py::dict& d)
|
||||
{
|
||||
MotionDef m;
|
||||
m.kind = str_to_enum(d["kind"].cast<std::string>(),
|
||||
kMotionKindEntries, "MotionKind");
|
||||
m.joint_id = d["joint_id"].cast<std::string>();
|
||||
if (d.contains("marker_i")) {
|
||||
m.marker_i = d["marker_i"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("marker_j")) {
|
||||
m.marker_j = d["marker_j"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("rotation_expr")) {
|
||||
m.rotation_expr = d["rotation_expr"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("translation_expr")) {
|
||||
m.translation_expr = d["translation_expr"].cast<std::string>();
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
py::dict sim_to_dict(const SimulationParams& s)
|
||||
{
|
||||
py::dict d;
|
||||
d["t_start"] = s.t_start;
|
||||
d["t_end"] = s.t_end;
|
||||
d["h_out"] = s.h_out;
|
||||
d["h_min"] = s.h_min;
|
||||
d["h_max"] = s.h_max;
|
||||
d["error_tol"] = s.error_tol;
|
||||
return d;
|
||||
}
|
||||
|
||||
SimulationParams sim_from_dict(const py::dict& d)
|
||||
{
|
||||
SimulationParams s;
|
||||
if (d.contains("t_start")) {
|
||||
s.t_start = d["t_start"].cast<double>();
|
||||
}
|
||||
if (d.contains("t_end")) {
|
||||
s.t_end = d["t_end"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_out")) {
|
||||
s.h_out = d["h_out"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_min")) {
|
||||
s.h_min = d["h_min"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_max")) {
|
||||
s.h_max = d["h_max"].cast<double>();
|
||||
}
|
||||
if (d.contains("error_tol")) {
|
||||
s.error_tol = d["error_tol"].cast<double>();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
py::dict diagnostic_to_dict(const ConstraintDiagnostic& diag)
|
||||
{
|
||||
py::dict d;
|
||||
d["constraint_id"] = diag.constraint_id;
|
||||
d["kind"] = enum_to_str(diag.kind, kDiagnosticKindEntries);
|
||||
d["detail"] = diag.detail;
|
||||
return d;
|
||||
}
|
||||
|
||||
ConstraintDiagnostic diagnostic_from_dict(const py::dict& d)
|
||||
{
|
||||
ConstraintDiagnostic diag;
|
||||
diag.constraint_id = d["constraint_id"].cast<std::string>();
|
||||
diag.kind = str_to_enum(d["kind"].cast<std::string>(),
|
||||
kDiagnosticKindEntries, "DiagnosticKind");
|
||||
if (d.contains("detail")) {
|
||||
diag.detail = d["detail"].cast<std::string>();
|
||||
}
|
||||
return diag;
|
||||
}
|
||||
|
||||
py::dict part_result_to_dict(const SolveResult::PartResult& pr)
|
||||
{
|
||||
py::dict d;
|
||||
d["id"] = pr.id;
|
||||
d["placement"] = transform_to_dict(pr.placement);
|
||||
return d;
|
||||
}
|
||||
|
||||
SolveResult::PartResult part_result_from_dict(const py::dict& d)
|
||||
{
|
||||
SolveResult::PartResult pr;
|
||||
pr.id = d["id"].cast<std::string>();
|
||||
pr.placement = transform_from_dict(d["placement"].cast<py::dict>());
|
||||
return pr;
|
||||
}
|
||||
|
||||
py::dict solve_context_to_dict(const SolveContext& ctx)
|
||||
{
|
||||
py::dict d;
|
||||
d["api_version"] = API_VERSION_MAJOR;
|
||||
|
||||
py::list parts;
|
||||
for (const auto& p : ctx.parts) {
|
||||
parts.append(part_to_dict(p));
|
||||
}
|
||||
d["parts"] = parts;
|
||||
|
||||
py::list constraints;
|
||||
for (const auto& c : ctx.constraints) {
|
||||
constraints.append(constraint_to_dict(c));
|
||||
}
|
||||
d["constraints"] = constraints;
|
||||
|
||||
py::list motions;
|
||||
for (const auto& m : ctx.motions) {
|
||||
motions.append(motion_to_dict(m));
|
||||
}
|
||||
d["motions"] = motions;
|
||||
|
||||
if (ctx.simulation.has_value()) {
|
||||
d["simulation"] = sim_to_dict(*ctx.simulation);
|
||||
}
|
||||
else {
|
||||
d["simulation"] = py::none();
|
||||
}
|
||||
|
||||
d["bundle_fixed"] = ctx.bundle_fixed;
|
||||
return d;
|
||||
}
|
||||
|
||||
SolveContext solve_context_from_dict(const py::dict& d)
|
||||
{
|
||||
SolveContext ctx;
|
||||
|
||||
if (d.contains("api_version")) {
|
||||
int v = d["api_version"].cast<int>();
|
||||
if (v != API_VERSION_MAJOR) {
|
||||
throw py::value_error(
|
||||
"Unsupported api_version " + std::to_string(v)
|
||||
+ ", expected " + std::to_string(API_VERSION_MAJOR));
|
||||
}
|
||||
}
|
||||
|
||||
for (auto item : d["parts"]) {
|
||||
ctx.parts.push_back(part_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
|
||||
for (auto item : d["constraints"]) {
|
||||
ctx.constraints.push_back(constraint_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
|
||||
if (d.contains("motions")) {
|
||||
for (auto item : d["motions"]) {
|
||||
ctx.motions.push_back(motion_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("simulation") && !d["simulation"].is_none()) {
|
||||
ctx.simulation = sim_from_dict(d["simulation"].cast<py::dict>());
|
||||
}
|
||||
|
||||
if (d.contains("bundle_fixed")) {
|
||||
ctx.bundle_fixed = d["bundle_fixed"].cast<bool>();
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
py::dict solve_result_to_dict(const SolveResult& r)
|
||||
{
|
||||
py::dict d;
|
||||
d["status"] = enum_to_str(r.status, kSolveStatusEntries);
|
||||
|
||||
py::list placements;
|
||||
for (const auto& pr : r.placements) {
|
||||
placements.append(part_result_to_dict(pr));
|
||||
}
|
||||
d["placements"] = placements;
|
||||
|
||||
d["dof"] = r.dof;
|
||||
|
||||
py::list diagnostics;
|
||||
for (const auto& diag : r.diagnostics) {
|
||||
diagnostics.append(diagnostic_to_dict(diag));
|
||||
}
|
||||
d["diagnostics"] = diagnostics;
|
||||
|
||||
d["num_frames"] = r.num_frames;
|
||||
return d;
|
||||
}
|
||||
|
||||
SolveResult solve_result_from_dict(const py::dict& d)
|
||||
{
|
||||
SolveResult r;
|
||||
r.status = str_to_enum(d["status"].cast<std::string>(),
|
||||
kSolveStatusEntries, "SolveStatus");
|
||||
|
||||
if (d.contains("placements")) {
|
||||
for (auto item : d["placements"]) {
|
||||
r.placements.push_back(part_result_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("dof")) {
|
||||
r.dof = d["dof"].cast<int>();
|
||||
}
|
||||
|
||||
if (d.contains("diagnostics")) {
|
||||
for (auto item : d["diagnostics"]) {
|
||||
r.diagnostics.push_back(diagnostic_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("num_frames")) {
|
||||
r.num_frames = d["num_frames"].cast<std::size_t>();
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
|
||||
|
||||
// ── PySolverHolder ─────────────────────────────────────────────────
|
||||
//
|
||||
// Wraps a Python IKCSolver subclass instance so it can live inside a
|
||||
@@ -216,14 +667,18 @@ PYBIND11_MODULE(kcsolve, m)
|
||||
+ std::to_string(t.position[0]) + ", "
|
||||
+ std::to_string(t.position[1]) + ", "
|
||||
+ std::to_string(t.position[2]) + "]>";
|
||||
});
|
||||
})
|
||||
.def("to_dict", [](const Transform& t) { return transform_to_dict(t); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return transform_from_dict(d); });
|
||||
|
||||
py::class_<Part>(m, "Part")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("id", &Part::id)
|
||||
.def_readwrite("placement", &Part::placement)
|
||||
.def_readwrite("mass", &Part::mass)
|
||||
.def_readwrite("grounded", &Part::grounded);
|
||||
.def_readwrite("grounded", &Part::grounded)
|
||||
.def("to_dict", [](const Part& p) { return part_to_dict(p); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return part_from_dict(d); });
|
||||
|
||||
auto constraint_class = py::class_<Constraint>(m, "Constraint");
|
||||
|
||||
@@ -231,7 +686,9 @@ PYBIND11_MODULE(kcsolve, m)
|
||||
.def(py::init<>())
|
||||
.def_readwrite("kind", &Constraint::Limit::kind)
|
||||
.def_readwrite("value", &Constraint::Limit::value)
|
||||
.def_readwrite("tolerance", &Constraint::Limit::tolerance);
|
||||
.def_readwrite("tolerance", &Constraint::Limit::tolerance)
|
||||
.def("to_dict", [](const Constraint::Limit& l) { return limit_to_dict(l); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return limit_from_dict(d); });
|
||||
|
||||
constraint_class
|
||||
.def(py::init<>())
|
||||
@@ -243,7 +700,9 @@ PYBIND11_MODULE(kcsolve, m)
|
||||
.def_readwrite("type", &Constraint::type)
|
||||
.def_readwrite("params", &Constraint::params)
|
||||
.def_readwrite("limits", &Constraint::limits)
|
||||
.def_readwrite("activated", &Constraint::activated);
|
||||
.def_readwrite("activated", &Constraint::activated)
|
||||
.def("to_dict", [](const Constraint& c) { return constraint_to_dict(c); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return constraint_from_dict(d); });
|
||||
|
||||
py::class_<MotionDef>(m, "MotionDef")
|
||||
.def(py::init<>())
|
||||
@@ -252,7 +711,9 @@ PYBIND11_MODULE(kcsolve, m)
|
||||
.def_readwrite("marker_i", &MotionDef::marker_i)
|
||||
.def_readwrite("marker_j", &MotionDef::marker_j)
|
||||
.def_readwrite("rotation_expr", &MotionDef::rotation_expr)
|
||||
.def_readwrite("translation_expr", &MotionDef::translation_expr);
|
||||
.def_readwrite("translation_expr", &MotionDef::translation_expr)
|
||||
.def("to_dict", [](const MotionDef& m) { return motion_to_dict(m); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return motion_from_dict(d); });
|
||||
|
||||
py::class_<SimulationParams>(m, "SimulationParams")
|
||||
.def(py::init<>())
|
||||
@@ -261,7 +722,9 @@ PYBIND11_MODULE(kcsolve, m)
|
||||
.def_readwrite("h_out", &SimulationParams::h_out)
|
||||
.def_readwrite("h_min", &SimulationParams::h_min)
|
||||
.def_readwrite("h_max", &SimulationParams::h_max)
|
||||
.def_readwrite("error_tol", &SimulationParams::error_tol);
|
||||
.def_readwrite("error_tol", &SimulationParams::error_tol)
|
||||
.def("to_dict", [](const SimulationParams& s) { return sim_to_dict(s); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return sim_from_dict(d); });
|
||||
|
||||
py::class_<SolveContext>(m, "SolveContext")
|
||||
.def(py::init<>())
|
||||
@@ -269,20 +732,26 @@ PYBIND11_MODULE(kcsolve, m)
|
||||
.def_readwrite("constraints", &SolveContext::constraints)
|
||||
.def_readwrite("motions", &SolveContext::motions)
|
||||
.def_readwrite("simulation", &SolveContext::simulation)
|
||||
.def_readwrite("bundle_fixed", &SolveContext::bundle_fixed);
|
||||
.def_readwrite("bundle_fixed", &SolveContext::bundle_fixed)
|
||||
.def("to_dict", [](const SolveContext& ctx) { return solve_context_to_dict(ctx); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return solve_context_from_dict(d); });
|
||||
|
||||
py::class_<ConstraintDiagnostic>(m, "ConstraintDiagnostic")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("constraint_id", &ConstraintDiagnostic::constraint_id)
|
||||
.def_readwrite("kind", &ConstraintDiagnostic::kind)
|
||||
.def_readwrite("detail", &ConstraintDiagnostic::detail);
|
||||
.def_readwrite("detail", &ConstraintDiagnostic::detail)
|
||||
.def("to_dict", [](const ConstraintDiagnostic& d) { return diagnostic_to_dict(d); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return diagnostic_from_dict(d); });
|
||||
|
||||
auto result_class = py::class_<SolveResult>(m, "SolveResult");
|
||||
|
||||
py::class_<SolveResult::PartResult>(result_class, "PartResult")
|
||||
.def(py::init<>())
|
||||
.def_readwrite("id", &SolveResult::PartResult::id)
|
||||
.def_readwrite("placement", &SolveResult::PartResult::placement);
|
||||
.def_readwrite("placement", &SolveResult::PartResult::placement)
|
||||
.def("to_dict", [](const SolveResult::PartResult& pr) { return part_result_to_dict(pr); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return part_result_from_dict(d); });
|
||||
|
||||
result_class
|
||||
.def(py::init<>())
|
||||
@@ -290,7 +759,9 @@ PYBIND11_MODULE(kcsolve, m)
|
||||
.def_readwrite("placements", &SolveResult::placements)
|
||||
.def_readwrite("dof", &SolveResult::dof)
|
||||
.def_readwrite("diagnostics", &SolveResult::diagnostics)
|
||||
.def_readwrite("num_frames", &SolveResult::num_frames);
|
||||
.def_readwrite("num_frames", &SolveResult::num_frames)
|
||||
.def("to_dict", [](const SolveResult& r) { return solve_result_to_dict(r); })
|
||||
.def_static("from_dict", [](const py::dict& d) { return solve_result_from_dict(d); });
|
||||
|
||||
// ── IKCSolver (with trampoline for Python subclassing) ─────────
|
||||
|
||||
|
||||
@@ -30,9 +30,11 @@ 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 TestSolverIntegration else False
|
||||
True if TestKindredSolverIntegration else False
|
||||
|
||||
@@ -84,3 +84,18 @@ install(
|
||||
DESTINATION
|
||||
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)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,12 @@ _KNOWN_ENTRIES = [
|
||||
"Dependencies",
|
||||
("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