diff --git a/.gitmodules b/.gitmodules
index e646a57f29..d20f33113e 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -13,6 +13,8 @@
[submodule "mods/ztools"]
path = mods/ztools
url = https://git.kindred-systems.com/forbes/ztools.git
+ branch = main
[submodule "mods/silo"]
path = mods/silo
url = https://git.kindred-systems.com/kindred/silo-mod.git
+ branch = main
diff --git a/mods/silo b/mods/silo
index 43e905c00a..dfa1da97dd 160000
--- a/mods/silo
+++ b/mods/silo
@@ -1 +1 @@
-Subproject commit 43e905c00a5d568c4fc37e0da1bf697a1b6119f7
+Subproject commit dfa1da97dd1f661a0e69f6b656900e55bd9dfb79
diff --git a/src/Mod/Assembly/App/AppAssembly.cpp b/src/Mod/Assembly/App/AppAssembly.cpp
index 118e18f029..b378c23f98 100644
--- a/src/Mod/Assembly/App/AppAssembly.cpp
+++ b/src/Mod/Assembly/App/AppAssembly.cpp
@@ -26,6 +26,8 @@
#include
#include
+#include
+
#include "AssemblyObject.h"
#include "AssemblyLink.h"
#include "BomObject.h"
@@ -54,6 +56,10 @@ PyMOD_INIT_FUNC(AssemblyApp)
}
PyObject* mod = Assembly::initModule();
+
+ // Register the built-in OndselSolver adapter with the solver registry.
+ KCSolve::OndselAdapter::register_solver();
+
Base::Console().log("Loading Assembly module... done\n");
diff --git a/src/Mod/Assembly/App/AssemblyObject.cpp b/src/Mod/Assembly/App/AssemblyObject.cpp
index ca480c88a5..89400b1f3c 100644
--- a/src/Mod/Assembly/App/AssemblyObject.cpp
+++ b/src/Mod/Assembly/App/AssemblyObject.cpp
@@ -23,6 +23,7 @@
#include
#include
+#include
#include
#include
@@ -43,39 +44,8 @@
#include
#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
+#include
+#include
#include "AssemblyLink.h"
#include "AssemblyObject.h"
@@ -87,19 +57,42 @@
FC_LOG_LEVEL_INIT("Assembly", true, true, true)
using namespace Assembly;
-using namespace MbD;
-
namespace PartApp = Part;
+// ── Transform conversion helpers ───────────────────────────────────
+
+static KCSolve::Transform placementToTransform(const Base::Placement& plc)
+{
+ KCSolve::Transform tf;
+ Base::Vector3d pos = plc.getPosition();
+ tf.position = {pos.x, pos.y, pos.z};
+
+ // Base::Rotation(q0,q1,q2,q3) = (x,y,z,w)
+ // KCSolve::Transform quaternion = (w,x,y,z)
+ double q0, q1, q2, q3;
+ plc.getRotation().getValue(q0, q1, q2, q3);
+ tf.quaternion = {q3, q0, q1, q2};
+
+ return tf;
+}
+
+static Base::Placement transformToPlacement(const KCSolve::Transform& tf)
+{
+ Base::Vector3d pos(tf.position[0], tf.position[1], tf.position[2]);
+ // KCSolve (w,x,y,z) → Base::Rotation(x,y,z,w)
+ Base::Rotation rot(tf.quaternion[1], tf.quaternion[2], tf.quaternion[3], tf.quaternion[0]);
+ return Base::Placement(pos, rot);
+}
+
+
// ================================ Assembly Object ============================
PROPERTY_SOURCE(Assembly::AssemblyObject, App::Part)
AssemblyObject::AssemblyObject()
- : mbdAssembly(std::make_shared())
- , bundleFixed(false)
+ : bundleFixed(false)
, lastDoF(0)
, lastHasConflict(false)
, lastHasRedundancies(false)
@@ -107,8 +100,6 @@ AssemblyObject::AssemblyObject()
, lastHasMalformedConstraints(false)
, lastSolverStatus(0)
{
- mbdAssembly->externalSystem->freecadAssemblyObject = this;
-
lastDoF = numberOfComponents() * 6;
signalSolverUpdate();
}
@@ -150,32 +141,47 @@ void AssemblyObject::onChanged(const App::Property* prop)
App::Part::onChanged(prop);
}
+
+// ── Solver integration ─────────────────────────────────────────────
+
+KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver()
+{
+ if (!solver_) {
+ solver_ = KCSolve::SolverRegistry::instance().get("ondsel");
+ }
+ return solver_.get();
+}
+
int AssemblyObject::solve(bool enableRedo, bool updateJCS)
{
ensureIdentityPlacements();
- mbdAssembly = makeMbdAssembly();
- objectPartMap.clear();
- motions.clear();
+ auto* solver = getOrCreateSolver();
+ if (!solver) {
+ FC_ERR("No solver available");
+ lastSolverStatus = -1;
+ return -1;
+ }
- auto groundedObjs = fixGroundedParts();
+ partIdToObjs_.clear();
+ objToPartId_.clear();
+
+ auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) {
- // If no part fixed we can't solve.
return -6;
}
std::vector joints = getJoints(updateJCS);
-
removeUnconnectedJoints(joints, groundedObjs);
- jointParts(joints);
+ KCSolve::SolveContext ctx = buildSolveContext(joints);
// Always save placements to enable orientation flip detection
savePlacementsForUndo();
try {
- mbdAssembly->runPreDrag();
- lastSolverStatus = 0;
+ lastResult_ = solver->solve(ctx);
+ lastSolverStatus = static_cast(lastResult_.status);
}
catch (const std::exception& e) {
FC_ERR("Solve failed: " << e.what());
@@ -190,6 +196,11 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
return -1;
}
+ if (lastResult_.status == KCSolve::SolveStatus::Failed) {
+ updateSolveStatus();
+ return -1;
+ }
+
// Validate that the solve didn't cause any parts to flip orientation
if (!validateNewPlacements()) {
// Restore previous placements - the solve found an invalid configuration
@@ -220,106 +231,96 @@ void AssemblyObject::updateSolveStatus()
//+1 because there's a grounded joint to origin
lastDoF = (1 + numberOfComponents()) * 6;
- if (!mbdAssembly || !mbdAssembly->mbdSystem) {
+ if (!solver_ || lastResult_.placements.empty()) {
solve();
}
- if (!mbdAssembly || !mbdAssembly->mbdSystem) {
+ if (!solver_) {
return;
}
- // Helper lambda to clean up the joint name from the solver
- auto cleanJointName = [](const std::string& rawName) -> std::string {
- // rawName is like : /OndselAssembly/ground_moves#Joint001
- size_t hashPos = rawName.find_last_of('#');
- if (hashPos != std::string::npos) {
- // Return the substring after the '#'
- return rawName.substr(hashPos + 1);
- }
- return rawName;
- };
-
-
- // Iterate through all joints and motions in the MBD system
- mbdAssembly->mbdSystem->jointsMotionsDo([&](std::shared_ptr jm) {
- if (!jm) {
- return;
- }
- // Base::Console().warning("jm->name %s\n", jm->name);
- bool isJointRedundant = false;
-
- jm->constraintsDo([&](std::shared_ptr con) {
- if (!con) {
- return;
- }
-
- std::string spec = con->constraintSpec();
- // A constraint is redundant if its spec starts with "Redundant"
- if (spec.rfind("Redundant", 0) == 0) {
- isJointRedundant = true;
- }
- // Base::Console().warning(" - %s\n", spec);
- --lastDoF;
- });
-
- const std::string fullName = cleanJointName(jm->name);
- App::DocumentObject* docObj = getDocument()->getObject(fullName.c_str());
-
- // We only care about objects that are actual joints in the FreeCAD document.
- // This effectively filters out the grounding joints, which are named after parts.
- if (!docObj || !docObj->getPropertyByName("Reference1")) {
- return;
- }
-
- if (isJointRedundant) {
- // Check if this joint is already in the list to avoid duplicates
- std::string objName = docObj->getNameInDocument();
- if (std::find(lastRedundantJoints.begin(), lastRedundantJoints.end(), objName)
- == lastRedundantJoints.end()) {
- lastRedundantJoints.push_back(objName);
- }
- }
- });
-
- // Update the summary boolean flag
- if (!lastRedundantJoints.empty()) {
- lastHasRedundancies = true;
+ // Use DOF from the solver result if available
+ if (lastResult_.dof >= 0) {
+ lastDoF = lastResult_.dof;
}
+ // Process diagnostics from the solver result
+ for (const auto& diag : lastResult_.diagnostics) {
+ // Filter to only actual FreeCAD joint objects (not grounding joints)
+ App::DocumentObject* docObj = getDocument()->getObject(diag.constraint_id.c_str());
+ if (!docObj || !docObj->getPropertyByName("Reference1")) {
+ continue;
+ }
+
+ std::string objName = docObj->getNameInDocument();
+
+ switch (diag.kind) {
+ case KCSolve::ConstraintDiagnostic::Kind::Redundant:
+ if (std::find(lastRedundantJoints.begin(), lastRedundantJoints.end(), objName)
+ == lastRedundantJoints.end()) {
+ lastRedundantJoints.push_back(objName);
+ }
+ break;
+ case KCSolve::ConstraintDiagnostic::Kind::Conflicting:
+ if (std::find(lastConflictingJoints.begin(), lastConflictingJoints.end(), objName)
+ == lastConflictingJoints.end()) {
+ lastConflictingJoints.push_back(objName);
+ }
+ break;
+ case KCSolve::ConstraintDiagnostic::Kind::PartiallyRedundant:
+ if (std::find(lastPartialRedundantJoints.begin(), lastPartialRedundantJoints.end(), objName)
+ == lastPartialRedundantJoints.end()) {
+ lastPartialRedundantJoints.push_back(objName);
+ }
+ break;
+ case KCSolve::ConstraintDiagnostic::Kind::Malformed:
+ if (std::find(lastMalformedJoints.begin(), lastMalformedJoints.end(), objName)
+ == lastMalformedJoints.end()) {
+ lastMalformedJoints.push_back(objName);
+ }
+ break;
+ }
+ }
+
+ lastHasRedundancies = !lastRedundantJoints.empty();
+ lastHasConflict = !lastConflictingJoints.empty();
+ lastHasPartialRedundancies = !lastPartialRedundantJoints.empty();
+ lastHasMalformedConstraints = !lastMalformedJoints.empty();
+
signalSolverUpdate();
}
int AssemblyObject::generateSimulation(App::DocumentObject* sim)
{
- mbdAssembly = makeMbdAssembly();
- objectPartMap.clear();
+ auto* solver = getOrCreateSolver();
+ if (!solver) {
+ return -1;
+ }
- motions = getMotionsFromSimulation(sim);
+ partIdToObjs_.clear();
+ objToPartId_.clear();
- auto groundedObjs = fixGroundedParts();
+ auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) {
- // If no part fixed we can't solve.
return -6;
}
std::vector joints = getJoints();
-
removeUnconnectedJoints(joints, groundedObjs);
- jointParts(joints);
-
- create_mbdSimulationParameters(sim);
+ KCSolve::SolveContext ctx = buildSolveContext(joints, true, sim);
try {
- mbdAssembly->runKINEMATIC();
+ lastResult_ = solver->run_kinematic(ctx);
}
catch (...) {
Base::Console().error("Generation of simulation failed\n");
- motions.clear();
return -1;
}
- motions.clear();
+ if (lastResult_.status == KCSolve::SolveStatus::Failed) {
+ return -1;
+ }
return 0;
}
@@ -340,16 +341,16 @@ std::vector AssemblyObject::getMotionsFromSimulation(App::
int Assembly::AssemblyObject::updateForFrame(size_t index, bool updateJCS)
{
- if (!mbdAssembly) {
+ if (!solver_) {
return -1;
}
- auto nfrms = mbdAssembly->numberOfFrames();
+ auto nfrms = solver_->num_frames();
if (index >= nfrms) {
return -1;
}
- mbdAssembly->updateForFrame(index);
+ lastResult_ = solver_->update_for_frame(index);
setNewPlacements();
auto jointDocs = getJoints(updateJCS);
redrawJointPlacements(jointDocs);
@@ -358,13 +359,32 @@ int Assembly::AssemblyObject::updateForFrame(size_t index, bool updateJCS)
size_t Assembly::AssemblyObject::numberOfFrames()
{
- return mbdAssembly->numberOfFrames();
+ return solver_ ? solver_->num_frames() : 0;
}
void AssemblyObject::preDrag(std::vector dragParts)
{
bundleFixed = true;
- solve();
+
+ auto* solver = getOrCreateSolver();
+ if (!solver) {
+ bundleFixed = false;
+ return;
+ }
+
+ partIdToObjs_.clear();
+ objToPartId_.clear();
+
+ auto groundedObjs = getGroundedParts();
+ if (groundedObjs.empty()) {
+ bundleFixed = false;
+ return;
+ }
+
+ std::vector joints = getJoints();
+ removeUnconnectedJoints(joints, groundedObjs);
+
+ KCSolve::SolveContext ctx = buildSolveContext(joints);
bundleFixed = false;
draggedParts.clear();
@@ -380,60 +400,68 @@ void AssemblyObject::preDrag(std::vector dragParts)
}
// Some objects have been bundled, we don't want to add these to dragged parts
- Base::Placement plc;
- for (auto& pair : objectPartMap) {
- App::DocumentObject* parti = pair.first;
- if (parti != part) {
- continue;
- }
- plc = pair.second.offsetPlc;
+ auto it = objToPartId_.find(part);
+ if (it == objToPartId_.end()) {
+ continue;
}
- if (!plc.isIdentity()) {
- // If not identity, then it's a bundled object. Some bundled objects may
- // have identity placement if they have the same position as the main object of
- // the bundle. But they're not going to be a problem.
+
+ // Check if this is a bundled (non-primary) object
+ const auto& mappings = partIdToObjs_[it->second];
+ bool isBundled = false;
+ for (const auto& m : mappings) {
+ if (m.obj == part && !m.offset.isIdentity()) {
+ isBundled = true;
+ break;
+ }
+ }
+ if (isBundled) {
continue;
}
draggedParts.push_back(part);
}
+
+ // Build drag part IDs for the solver
+ std::vector dragPartIds;
+ for (auto* part : draggedParts) {
+ auto idIt = objToPartId_.find(part);
+ if (idIt != objToPartId_.end()) {
+ dragPartIds.push_back(idIt->second);
+ }
+ }
+
+ savePlacementsForUndo();
+
+ try {
+ lastResult_ = solver->pre_drag(ctx, dragPartIds);
+ setNewPlacements();
+ }
+ catch (...) {
+ // If pre_drag fails, we still need to be in a valid state
+ }
}
void AssemblyObject::doDragStep()
{
try {
- std::vector> dragMbdParts;
+ std::vector dragPlacements;
for (auto& part : draggedParts) {
if (!part) {
continue;
}
- auto mbdPart = getMbDPart(part);
- dragMbdParts.push_back(mbdPart);
+ auto idIt = objToPartId_.find(part);
+ if (idIt == objToPartId_.end()) {
+ continue;
+ }
- // Update the MBD part's position
Base::Placement plc = getPlacementFromProp(part, "Placement");
- Base::Vector3d pos = plc.getPosition();
- mbdPart->updateMbDFromPosition3D(
- std::make_shared>(ListD {pos.x, pos.y, pos.z})
- );
-
- // Update the MBD part's rotation
- Base::Rotation rot = plc.getRotation();
- Base::Matrix4D mat;
- rot.getValue(mat);
- Base::Vector3d r0 = mat.getRow(0);
- Base::Vector3d r1 = mat.getRow(1);
- Base::Vector3d r2 = mat.getRow(2);
- mbdPart->updateMbDFromRotationMatrix(r0.x, r0.y, r0.z, r1.x, r1.y, r1.z, r2.x, r2.y, r2.z);
+ dragPlacements.push_back({idIt->second, placementToTransform(plc)});
}
- // Timing mbdAssembly->runDragStep()
- auto dragPartsVec = std::make_shared>>(dragMbdParts);
- mbdAssembly->runDragStep(dragPartsVec);
+ lastResult_ = solver_->drag_step(dragPlacements);
- // Timing the validation and placement setting
if (validateNewPlacements()) {
setNewPlacements();
@@ -451,23 +479,6 @@ void AssemblyObject::doDragStep()
}
}
-Base::Placement AssemblyObject::getMbdPlacement(std::shared_ptr mbdPart)
-{
- if (!mbdPart) {
- return Base::Placement();
- }
-
- double x, y, z;
- mbdPart->getPosition3D(x, y, z);
- Base::Vector3d pos = Base::Vector3d(x, y, z);
-
- double q0, q1, q2, q3;
- mbdPart->getQuarternions(q3, q0, q1, q2);
- Base::Rotation rot = Base::Rotation(q0, q1, q2, q3);
-
- return Base::Placement(pos, rot);
-}
-
bool AssemblyObject::validateNewPlacements()
{
// First we check if a grounded object has moved. It can happen that they flip.
@@ -479,12 +490,26 @@ bool AssemblyObject::validateNewPlacements()
if (propPlacement) {
Base::Placement oldPlc = propPlacement->getValue();
- auto it = objectPartMap.find(obj);
- if (it != objectPartMap.end()) {
- std::shared_ptr mbdPart = it->second.part;
- Base::Placement newPlacement = getMbdPlacement(mbdPart);
- if (!it->second.offsetPlc.isIdentity()) {
- newPlacement = newPlacement * it->second.offsetPlc;
+ auto idIt = objToPartId_.find(obj);
+ if (idIt == objToPartId_.end()) {
+ continue;
+ }
+
+ // Find the new placement from lastResult_
+ for (const auto& pr : lastResult_.placements) {
+ if (pr.id != idIt->second) {
+ continue;
+ }
+
+ Base::Placement newPlacement = transformToPlacement(pr.placement);
+
+ // Apply bundle offset if present
+ const auto& mappings = partIdToObjs_[pr.id];
+ for (const auto& m : mappings) {
+ if (m.obj == obj && !m.offset.isIdentity()) {
+ newPlacement = newPlacement * m.offset;
+ break;
+ }
}
if (!oldPlc.isSame(newPlacement, Precision::Confusion())) {
@@ -494,57 +519,66 @@ bool AssemblyObject::validateNewPlacements()
);
return false;
}
+ break;
}
}
}
// Check if any part has flipped orientation (rotation > 90 degrees from original)
- // This prevents joints from "breaking" when the solver finds an alternate configuration
for (const auto& savedPair : previousPositions) {
App::DocumentObject* obj = savedPair.first;
if (!obj) {
continue;
}
- auto it = objectPartMap.find(obj);
- if (it == objectPartMap.end()) {
+ auto idIt = objToPartId_.find(obj);
+ if (idIt == objToPartId_.end()) {
continue;
}
- std::shared_ptr mbdPart = it->second.part;
- if (!mbdPart) {
- continue;
- }
+ // Find the new placement from lastResult_
+ for (const auto& pr : lastResult_.placements) {
+ if (pr.id != idIt->second) {
+ continue;
+ }
- Base::Placement newPlacement = getMbdPlacement(mbdPart);
- if (!it->second.offsetPlc.isIdentity()) {
- newPlacement = newPlacement * it->second.offsetPlc;
- }
+ Base::Placement newPlacement = transformToPlacement(pr.placement);
- const Base::Placement& oldPlc = savedPair.second;
+ // Apply bundle offset if present
+ const auto& mappings = partIdToObjs_[pr.id];
+ for (const auto& m : mappings) {
+ if (m.obj == obj && !m.offset.isIdentity()) {
+ newPlacement = newPlacement * m.offset;
+ break;
+ }
+ }
- // Calculate the rotation difference between old and new orientations
- Base::Rotation oldRot = oldPlc.getRotation();
- Base::Rotation newRot = newPlacement.getRotation();
+ const Base::Placement& oldPlc = savedPair.second;
- // Get the relative rotation: how much did the part rotate?
- Base::Rotation relativeRot = newRot * oldRot.inverse();
+ // Calculate the rotation difference between old and new orientations
+ Base::Rotation oldRot = oldPlc.getRotation();
+ Base::Rotation newRot = newPlacement.getRotation();
- // Get the angle of this rotation
- Base::Vector3d axis;
- double angle;
- relativeRot.getRawValue(axis, angle);
+ // Get the relative rotation: how much did the part rotate?
+ Base::Rotation relativeRot = newRot * oldRot.inverse();
- // If the part rotated more than 90 degrees, consider it a flip
- // Use 91 degrees to allow for small numerical errors
- constexpr double maxAngle = 91.0 * M_PI / 180.0;
- if (std::abs(angle) > maxAngle) {
- Base::Console().warning(
- "Assembly : Ignoring bad solve, part (%s) flipped orientation (%.1f degrees).\n",
- obj->getFullLabel(),
- std::abs(angle) * 180.0 / M_PI
- );
- return false;
+ // Get the angle of this rotation
+ Base::Vector3d axis;
+ double angle;
+ relativeRot.getRawValue(axis, angle);
+
+ // If the part rotated more than 90 degrees, consider it a flip
+ // Use 91 degrees to allow for small numerical errors
+ constexpr double maxAngle = 91.0 * M_PI / 180.0;
+ if (std::abs(angle) > maxAngle) {
+ Base::Console().warning(
+ "Assembly : Ignoring bad solve, part (%s) flipped orientation (%.1f degrees).\n",
+ obj->getFullLabel(),
+ std::abs(angle) * 180.0 / M_PI
+ );
+ return false;
+ }
+ break;
}
}
@@ -553,7 +587,9 @@ bool AssemblyObject::validateNewPlacements()
void AssemblyObject::postDrag()
{
- mbdAssembly->runPostDrag(); // Do this after last drag
+ if (solver_) {
+ solver_->post_drag();
+ }
purgeTouched();
}
@@ -561,23 +597,20 @@ void AssemblyObject::savePlacementsForUndo()
{
previousPositions.clear();
- for (auto& pair : objectPartMap) {
- App::DocumentObject* obj = pair.first;
- if (!obj) {
- continue;
+ for (const auto& [partId, mappings] : partIdToObjs_) {
+ for (const auto& mapping : mappings) {
+ App::DocumentObject* obj = mapping.obj;
+ if (!obj) {
+ continue;
+ }
+
+ auto* propPlc = dynamic_cast(obj->getPropertyByName("Placement"));
+ if (!propPlc) {
+ continue;
+ }
+
+ previousPositions.push_back({obj, propPlc->getValue()});
}
-
- std::pair savePair;
- savePair.first = obj;
-
- // Check if the object has a "Placement" property
- auto* propPlc = dynamic_cast(obj->getPropertyByName("Placement"));
- if (!propPlc) {
- continue;
- }
- savePair.second = propPlc->getValue();
-
- previousPositions.push_back(savePair);
}
}
@@ -616,43 +649,61 @@ void AssemblyObject::clearUndo()
void AssemblyObject::exportAsASMT(std::string fileName)
{
- mbdAssembly = makeMbdAssembly();
- objectPartMap.clear();
- fixGroundedParts();
+ auto* solver = getOrCreateSolver();
+ if (!solver) {
+ return;
+ }
+ partIdToObjs_.clear();
+ objToPartId_.clear();
+
+ auto groundedObjs = getGroundedParts();
std::vector joints = getJoints();
+ removeUnconnectedJoints(joints, groundedObjs);
- jointParts(joints);
+ KCSolve::SolveContext ctx = buildSolveContext(joints);
- mbdAssembly->outputFile(fileName);
+ try {
+ solver->solve(ctx);
+ }
+ catch (...) {
+ // Build anyway for export
+ }
+
+ solver->export_native(fileName);
}
void AssemblyObject::setNewPlacements()
{
- for (auto& pair : objectPartMap) {
- App::DocumentObject* obj = pair.first;
- std::shared_ptr mbdPart = pair.second.part;
-
- if (!obj || !mbdPart) {
+ for (const auto& pr : lastResult_.placements) {
+ auto it = partIdToObjs_.find(pr.id);
+ if (it == partIdToObjs_.end()) {
continue;
}
- // Check if the object has a "Placement" property
- auto* propPlacement = dynamic_cast(
- obj->getPropertyByName("Placement")
- );
- if (!propPlacement) {
- continue;
- }
+ Base::Placement basePlc = transformToPlacement(pr.placement);
+ for (const auto& mapping : it->second) {
+ App::DocumentObject* obj = mapping.obj;
+ if (!obj) {
+ continue;
+ }
- Base::Placement newPlacement = getMbdPlacement(mbdPart);
- if (!pair.second.offsetPlc.isIdentity()) {
- newPlacement = newPlacement * pair.second.offsetPlc;
- }
- if (!propPlacement->getValue().isSame(newPlacement)) {
- propPlacement->setValue(newPlacement);
- obj->purgeTouched();
+ auto* propPlacement = dynamic_cast(
+ obj->getPropertyByName("Placement")
+ );
+ if (!propPlacement) {
+ continue;
+ }
+
+ Base::Placement newPlacement = basePlc;
+ if (!mapping.offset.isIdentity()) {
+ newPlacement = basePlc * mapping.offset;
+ }
+ if (!propPlacement->getValue().isSame(newPlacement)) {
+ propPlacement->setValue(newPlacement);
+ obj->purgeTouched();
+ }
}
}
}
@@ -698,20 +749,726 @@ void AssemblyObject::redrawJointPlacement(App::DocumentObject* joint)
}
}
-std::shared_ptr AssemblyObject::makeMbdAssembly()
+
+// ── SolveContext building ──────────────────────────────────────────
+
+std::string AssemblyObject::registerPart(App::DocumentObject* obj)
{
- auto assembly = CREATE::With();
- assembly->externalSystem->freecadAssemblyObject = this;
- assembly->setName("OndselAssembly");
+ // Check if already registered
+ auto it = objToPartId_.find(obj);
+ if (it != objToPartId_.end()) {
+ return it->second;
+ }
- ParameterGrp::handle hPgr = App::GetApplication().GetParameterGroupByPath(
- "User parameter:BaseApp/Preferences/Mod/Assembly"
- );
+ std::string partId = obj->getFullName();
+ Base::Placement plc = getPlacementFromProp(obj, "Placement");
- assembly->setDebug(hPgr->GetBool("LogSolverDebug", false));
- return assembly;
+ objToPartId_[obj] = partId;
+ partIdToObjs_[partId].push_back({obj, Base::Placement()});
+
+ // When bundling fixed joints, recursively discover connected parts
+ if (bundleFixed) {
+ auto addConnectedFixedParts = [&](App::DocumentObject* currentPart, auto& self) -> void {
+ std::vector joints = getJointsOfPart(currentPart);
+ for (auto* joint : joints) {
+ JointType jointType = getJointType(joint);
+ if (jointType == JointType::Fixed) {
+ App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
+ App::DocumentObject* part2 = getMovingPartFromRef(joint, "Reference2");
+ App::DocumentObject* partToAdd = currentPart == part1 ? part2 : part1;
+
+ if (objToPartId_.find(partToAdd) != objToPartId_.end()) {
+ continue; // already registered
+ }
+
+ Base::Placement plci = getPlacementFromProp(partToAdd, "Placement");
+ Base::Placement offset = plc.inverse() * plci;
+
+ objToPartId_[partToAdd] = partId;
+ partIdToObjs_[partId].push_back({partToAdd, offset});
+
+ self(partToAdd, self);
+ }
+ }
+ };
+
+ addConnectedFixedParts(obj, addConnectedFixedParts);
+ }
+
+ return partId;
}
+KCSolve::SolveContext AssemblyObject::buildSolveContext(
+ const std::vector& joints,
+ bool forSimulation,
+ App::DocumentObject* sim
+)
+{
+ KCSolve::SolveContext ctx;
+ ctx.bundle_fixed = bundleFixed;
+
+ // ── Parts: register grounded parts ─────────────────────────────
+
+ auto groundedObjs = getGroundedParts();
+ for (auto* obj : groundedObjs) {
+ if (!obj) {
+ continue;
+ }
+
+ std::string partId = registerPart(obj);
+ Base::Placement plc = getPlacementFromProp(obj, "Placement");
+
+ KCSolve::Part part;
+ part.id = partId;
+ part.placement = placementToTransform(plc);
+ part.mass = getObjMass(obj);
+ part.grounded = true;
+ ctx.parts.push_back(std::move(part));
+ }
+
+ // ── Constraints: process each joint ────────────────────────────
+
+ // Collect motions for simulation
+ std::vector motionObjs;
+ if (forSimulation && sim) {
+ motionObjs = getMotionsFromSimulation(sim);
+ }
+
+ for (auto* joint : joints) {
+ if (!joint) {
+ continue;
+ }
+
+ JointType jointType = getJointType(joint);
+
+ // When bundling fixed joints, skip Fixed type (parts are already bundled)
+ if (bundleFixed && jointType == JointType::Fixed) {
+ continue;
+ }
+
+ // Determine BaseJointKind and params
+ KCSolve::BaseJointKind kind;
+ std::vector params;
+
+ switch (jointType) {
+ case JointType::Fixed:
+ kind = KCSolve::BaseJointKind::Fixed;
+ break;
+ case JointType::Revolute:
+ kind = KCSolve::BaseJointKind::Revolute;
+ break;
+ case JointType::Cylindrical:
+ kind = KCSolve::BaseJointKind::Cylindrical;
+ break;
+ case JointType::Slider:
+ kind = KCSolve::BaseJointKind::Slider;
+ break;
+ case JointType::Ball:
+ kind = KCSolve::BaseJointKind::Ball;
+ break;
+ case JointType::Parallel:
+ kind = KCSolve::BaseJointKind::Parallel;
+ break;
+ case JointType::Perpendicular:
+ kind = KCSolve::BaseJointKind::Perpendicular;
+ break;
+ case JointType::Angle: {
+ double angle = fabs(Base::toRadians(getJointAngle(joint)));
+ if (fmod(angle, 2 * std::numbers::pi) < Precision::Confusion()) {
+ kind = KCSolve::BaseJointKind::Parallel;
+ }
+ else {
+ kind = KCSolve::BaseJointKind::Angle;
+ params.push_back(angle);
+ }
+ break;
+ }
+ case JointType::RackPinion: {
+ kind = KCSolve::BaseJointKind::RackPinion;
+ params.push_back(getJointDistance(joint));
+ break;
+ }
+ case JointType::Screw: {
+ int slidingIndex = slidingPartIndex(joint);
+ if (slidingIndex == 0) {
+ continue; // invalid — needs a slider
+ }
+ if (slidingIndex != 1) {
+ swapJCS(joint);
+ }
+ kind = KCSolve::BaseJointKind::Screw;
+ params.push_back(getJointDistance(joint));
+ break;
+ }
+ case JointType::Gears: {
+ kind = KCSolve::BaseJointKind::Gear;
+ params.push_back(getJointDistance(joint));
+ params.push_back(getJointDistance2(joint));
+ break;
+ }
+ case JointType::Belt: {
+ kind = KCSolve::BaseJointKind::Gear;
+ params.push_back(getJointDistance(joint));
+ params.push_back(-getJointDistance2(joint));
+ break;
+ }
+ case JointType::Distance: {
+ // Decompose based on geometry classification
+ DistanceType distType = getDistanceType(joint);
+ std::string elt1 = getElementFromProp(joint, "Reference1");
+ std::string elt2 = getElementFromProp(joint, "Reference2");
+ auto* obj1 = getLinkedObjFromRef(joint, "Reference1");
+ auto* obj2 = getLinkedObjFromRef(joint, "Reference2");
+ double distance = getJointDistance(joint);
+
+ switch (distType) {
+ case DistanceType::PointPoint:
+ if (distance < Precision::Confusion()) {
+ kind = KCSolve::BaseJointKind::Coincident;
+ }
+ else {
+ kind = KCSolve::BaseJointKind::DistancePointPoint;
+ params.push_back(distance);
+ }
+ break;
+
+ case DistanceType::LineLine:
+ kind = KCSolve::BaseJointKind::Concentric;
+ params.push_back(distance);
+ break;
+ case DistanceType::LineCircle:
+ kind = KCSolve::BaseJointKind::Concentric;
+ params.push_back(distance + getEdgeRadius(obj2, elt2));
+ break;
+ case DistanceType::CircleCircle:
+ kind = KCSolve::BaseJointKind::Concentric;
+ params.push_back(distance + getEdgeRadius(obj1, elt1) + getEdgeRadius(obj2, elt2));
+ break;
+
+ case DistanceType::PlanePlane:
+ kind = KCSolve::BaseJointKind::Planar;
+ params.push_back(distance);
+ break;
+ case DistanceType::PlaneCylinder:
+ kind = KCSolve::BaseJointKind::LineInPlane;
+ params.push_back(distance + getFaceRadius(obj2, elt2));
+ break;
+ case DistanceType::PlaneSphere:
+ kind = KCSolve::BaseJointKind::PointInPlane;
+ params.push_back(distance + getFaceRadius(obj2, elt2));
+ break;
+ case DistanceType::PlaneTorus:
+ kind = KCSolve::BaseJointKind::Planar;
+ params.push_back(distance);
+ break;
+
+ case DistanceType::CylinderCylinder:
+ kind = KCSolve::BaseJointKind::Concentric;
+ params.push_back(distance + getFaceRadius(obj1, elt1) + getFaceRadius(obj2, elt2));
+ break;
+ case DistanceType::CylinderSphere:
+ kind = KCSolve::BaseJointKind::DistanceCylSph;
+ params.push_back(distance + getFaceRadius(obj1, elt1) + getFaceRadius(obj2, elt2));
+ break;
+ case DistanceType::CylinderTorus:
+ kind = KCSolve::BaseJointKind::Concentric;
+ params.push_back(distance + getFaceRadius(obj1, elt1) + getFaceRadius(obj2, elt2));
+ break;
+
+ case DistanceType::TorusTorus:
+ kind = KCSolve::BaseJointKind::Planar;
+ params.push_back(distance);
+ break;
+ case DistanceType::TorusSphere:
+ kind = KCSolve::BaseJointKind::DistanceCylSph;
+ params.push_back(distance + getFaceRadius(obj1, elt1) + getFaceRadius(obj2, elt2));
+ break;
+ case DistanceType::SphereSphere:
+ kind = KCSolve::BaseJointKind::DistancePointPoint;
+ params.push_back(distance + getFaceRadius(obj1, elt1) + getFaceRadius(obj2, elt2));
+ break;
+
+ case DistanceType::PointPlane:
+ kind = KCSolve::BaseJointKind::PointInPlane;
+ params.push_back(distance);
+ break;
+ case DistanceType::PointCylinder:
+ kind = KCSolve::BaseJointKind::DistanceCylSph;
+ params.push_back(distance + getFaceRadius(obj1, elt1));
+ break;
+ case DistanceType::PointSphere:
+ kind = KCSolve::BaseJointKind::DistancePointPoint;
+ params.push_back(distance + getFaceRadius(obj1, elt1));
+ break;
+
+ case DistanceType::LinePlane:
+ kind = KCSolve::BaseJointKind::LineInPlane;
+ params.push_back(distance);
+ break;
+
+ case DistanceType::PointLine:
+ kind = KCSolve::BaseJointKind::DistanceCylSph;
+ params.push_back(distance);
+ break;
+ case DistanceType::PointCurve:
+ kind = KCSolve::BaseJointKind::PointInPlane;
+ params.push_back(distance);
+ break;
+
+ default:
+ kind = KCSolve::BaseJointKind::Planar;
+ params.push_back(distance);
+ break;
+ }
+ break;
+ }
+ default:
+ continue;
+ }
+
+ // Validate the joint (skip self-referential bundled parts)
+ if (!isJointValid(joint)) {
+ continue;
+ }
+
+ // Compute marker transforms
+ std::string partIdI, partIdJ;
+ KCSolve::Transform markerI, markerJ;
+
+ if (jointType == JointType::RackPinion) {
+ auto rp = computeRackPinionMarkers(joint);
+ partIdI = rp.partIdI;
+ markerI = rp.markerI;
+ partIdJ = rp.partIdJ;
+ markerJ = rp.markerJ;
+
+ if (partIdI.empty() || partIdJ.empty()) {
+ continue;
+ }
+ }
+ else {
+ // Resolve part IDs from joint references
+ App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
+ App::DocumentObject* part2 = getMovingPartFromRef(joint, "Reference2");
+ if (!part1 || !part2) {
+ continue;
+ }
+
+ // Ensure both parts are registered
+ partIdI = registerPart(part1);
+ partIdJ = registerPart(part2);
+
+ markerI = computeMarkerTransform(joint, "Reference1", "Placement1");
+ markerJ = computeMarkerTransform(joint, "Reference2", "Placement2");
+ }
+
+ // Build the constraint
+ KCSolve::Constraint c;
+ c.id = joint->getNameInDocument();
+ c.part_i = partIdI;
+ c.marker_i = markerI;
+ c.part_j = partIdJ;
+ c.marker_j = markerJ;
+ c.type = kind;
+ c.params = std::move(params);
+
+ // Add limits (only if not in simulation mode — motions may clash)
+ if (motionObjs.empty()) {
+ if (jointType == JointType::Slider || jointType == JointType::Cylindrical) {
+ auto* pLenMin = dynamic_cast(joint->getPropertyByName("LengthMin"));
+ auto* pLenMax = dynamic_cast(joint->getPropertyByName("LengthMax"));
+ auto* pMinEnabled = dynamic_cast(
+ joint->getPropertyByName("EnableLengthMin")
+ );
+ auto* pMaxEnabled = dynamic_cast(
+ joint->getPropertyByName("EnableLengthMax")
+ );
+
+ if (pLenMin && pLenMax && pMinEnabled && pMaxEnabled) {
+ bool minEnabled = pMinEnabled->getValue();
+ bool maxEnabled = pMaxEnabled->getValue();
+ double minLength = pLenMin->getValue();
+ double maxLength = pLenMax->getValue();
+
+ if ((minLength > maxLength) && minEnabled && maxEnabled) {
+ pLenMin->setValue(maxLength);
+ pLenMax->setValue(minLength);
+ minLength = maxLength;
+ maxLength = pLenMax->getValue();
+
+ pMinEnabled->setValue(maxEnabled);
+ pMaxEnabled->setValue(minEnabled);
+ minEnabled = maxEnabled;
+ maxEnabled = pMaxEnabled->getValue();
+ }
+
+ if (minEnabled) {
+ c.limits.push_back({
+ KCSolve::Constraint::Limit::Kind::TranslationMin,
+ minLength,
+ 1.0e-9
+ });
+ }
+ if (maxEnabled) {
+ c.limits.push_back({
+ KCSolve::Constraint::Limit::Kind::TranslationMax,
+ maxLength,
+ 1.0e-9
+ });
+ }
+ }
+ }
+ if (jointType == JointType::Revolute || jointType == JointType::Cylindrical) {
+ auto* pRotMin = dynamic_cast(joint->getPropertyByName("AngleMin"));
+ auto* pRotMax = dynamic_cast(joint->getPropertyByName("AngleMax"));
+ auto* pMinEnabled = dynamic_cast(
+ joint->getPropertyByName("EnableAngleMin")
+ );
+ auto* pMaxEnabled = dynamic_cast(
+ joint->getPropertyByName("EnableAngleMax")
+ );
+
+ if (pRotMin && pRotMax && pMinEnabled && pMaxEnabled) {
+ bool minEnabled = pMinEnabled->getValue();
+ bool maxEnabled = pMaxEnabled->getValue();
+ double minAngle = pRotMin->getValue();
+ double maxAngle = pRotMax->getValue();
+
+ if ((minAngle > maxAngle) && minEnabled && maxEnabled) {
+ pRotMin->setValue(maxAngle);
+ pRotMax->setValue(minAngle);
+ minAngle = maxAngle;
+ maxAngle = pRotMax->getValue();
+
+ pMinEnabled->setValue(maxEnabled);
+ pMaxEnabled->setValue(minEnabled);
+ minEnabled = maxEnabled;
+ maxEnabled = pMaxEnabled->getValue();
+ }
+
+ if (minEnabled) {
+ c.limits.push_back({
+ KCSolve::Constraint::Limit::Kind::RotationMin,
+ minAngle,
+ 1.0e-9
+ });
+ }
+ if (maxEnabled) {
+ c.limits.push_back({
+ KCSolve::Constraint::Limit::Kind::RotationMax,
+ maxAngle,
+ 1.0e-9
+ });
+ }
+ }
+ }
+ }
+
+ ctx.constraints.push_back(std::move(c));
+
+ // Add motions for simulation
+ if (forSimulation) {
+ std::vector done;
+ for (auto* motion : motionObjs) {
+ if (std::ranges::find(done, motion) != done.end()) {
+ continue;
+ }
+
+ auto* pJoint = dynamic_cast(motion->getPropertyByName("Joint"));
+ if (!pJoint) {
+ continue;
+ }
+ App::DocumentObject* motionJoint = pJoint->getValue();
+ if (joint != motionJoint) {
+ continue;
+ }
+
+ auto* pType = dynamic_cast(motion->getPropertyByName("MotionType"));
+ auto* pFormula = dynamic_cast(motion->getPropertyByName("Formula"));
+ if (!pType || !pFormula) {
+ continue;
+ }
+ std::string formula = pFormula->getValue();
+ if (formula.empty()) {
+ continue;
+ }
+ std::string motionType = pType->getValueAsString();
+
+ // Check for paired motion (cylindrical joints can have both angular + linear)
+ for (auto* motion2 : motionObjs) {
+ auto* pJoint2 = dynamic_cast(motion2->getPropertyByName("Joint"));
+ if (!pJoint2) {
+ continue;
+ }
+ App::DocumentObject* motionJoint2 = pJoint2->getValue();
+ if (joint != motionJoint2 || motion2 == motion) {
+ continue;
+ }
+
+ auto* pType2 = dynamic_cast(
+ motion2->getPropertyByName("MotionType")
+ );
+ auto* pFormula2 = dynamic_cast(motion2->getPropertyByName("Formula"));
+ if (!pType2 || !pFormula2) {
+ continue;
+ }
+ std::string formula2 = pFormula2->getValue();
+ if (formula2.empty()) {
+ continue;
+ }
+ std::string motionType2 = pType2->getValueAsString();
+ if (motionType2 == motionType) {
+ continue;
+ }
+
+ // Two different motion types on same joint → General motion
+ KCSolve::MotionDef md;
+ md.kind = KCSolve::MotionDef::Kind::General;
+ md.joint_id = joint->getNameInDocument();
+ md.marker_i = ""; // Adapter resolves from joint_id
+ md.marker_j = "";
+ md.rotation_expr = motionType == "Angular" ? formula : formula2;
+ md.translation_expr = motionType == "Angular" ? formula2 : formula;
+ ctx.motions.push_back(std::move(md));
+
+ done.push_back(motion2);
+ }
+
+ // Single motion
+ KCSolve::MotionDef md;
+ md.joint_id = joint->getNameInDocument();
+ md.marker_i = "";
+ md.marker_j = "";
+ if (motionType == "Angular") {
+ md.kind = KCSolve::MotionDef::Kind::Rotational;
+ md.rotation_expr = formula;
+ }
+ else {
+ md.kind = KCSolve::MotionDef::Kind::Translational;
+ md.translation_expr = formula;
+ }
+ ctx.motions.push_back(std::move(md));
+ }
+ }
+ }
+
+ // ── Parts: ensure all referenced parts are in the context ──────
+
+ // Some parts may have been registered during constraint processing
+ // but not yet added to ctx.parts
+ for (const auto& [partId, mappings] : partIdToObjs_) {
+ bool alreadyInCtx = false;
+ for (const auto& p : ctx.parts) {
+ if (p.id == partId) {
+ alreadyInCtx = true;
+ break;
+ }
+ }
+ if (!alreadyInCtx) {
+ App::DocumentObject* primaryObj = mappings[0].obj;
+ Base::Placement plc = getPlacementFromProp(primaryObj, "Placement");
+
+ KCSolve::Part part;
+ part.id = partId;
+ part.placement = placementToTransform(plc);
+ part.mass = getObjMass(primaryObj);
+ part.grounded = false;
+ ctx.parts.push_back(std::move(part));
+ }
+ }
+
+ // ── Simulation parameters ──────────────────────────────────────
+
+ if (forSimulation && sim) {
+ auto valueOf = [](App::DocumentObject* docObj, const char* propName) {
+ auto* prop = dynamic_cast(docObj->getPropertyByName(propName));
+ if (!prop) {
+ return 0.0;
+ }
+ return prop->getValue();
+ };
+
+ KCSolve::SimulationParams sp;
+ sp.t_start = valueOf(sim, "aTimeStart");
+ sp.t_end = valueOf(sim, "bTimeEnd");
+ sp.h_out = valueOf(sim, "cTimeStepOutput");
+ sp.h_min = 1.0e-9;
+ sp.h_max = 1.0;
+ sp.error_tol = valueOf(sim, "fGlobalErrorTolerance");
+ ctx.simulation = sp;
+ }
+
+ return ctx;
+}
+
+
+// ── Marker transform computation ───────────────────────────────────
+
+KCSolve::Transform AssemblyObject::computeMarkerTransform(
+ App::DocumentObject* joint,
+ const char* propRefName,
+ const char* propPlcName
+)
+{
+ App::DocumentObject* part = getMovingPartFromRef(joint, propRefName);
+ App::DocumentObject* obj = getObjFromJointRef(joint, propRefName);
+
+ if (!part || !obj) {
+ Base::Console()
+ .warning("The property %s of Joint %s is bad.\n", propRefName, joint->getFullName());
+ return KCSolve::Transform::identity();
+ }
+
+ Base::Placement plc = getPlacementFromProp(joint, propPlcName);
+ // Now we have plc which is the JCS placement, but its relative to the Object, not to the
+ // containing Part.
+
+ if (obj->getNameInDocument() != part->getNameInDocument()) {
+ auto* ref = dynamic_cast(joint->getPropertyByName(propRefName));
+ if (!ref) {
+ return KCSolve::Transform::identity();
+ }
+
+ Base::Placement obj_global_plc = getGlobalPlacement(obj, ref);
+ plc = obj_global_plc * plc;
+
+ Base::Placement part_global_plc = getGlobalPlacement(part, ref);
+ plc = part_global_plc.inverse() * plc;
+ }
+
+ // Apply bundle offset if present
+ auto idIt = objToPartId_.find(part);
+ if (idIt != objToPartId_.end()) {
+ const auto& mappings = partIdToObjs_[idIt->second];
+ for (const auto& m : mappings) {
+ if (m.obj == part && !m.offset.isIdentity()) {
+ plc = m.offset * plc;
+ break;
+ }
+ }
+ }
+
+ return placementToTransform(plc);
+}
+
+AssemblyObject::RackPinionResult AssemblyObject::computeRackPinionMarkers(App::DocumentObject* joint)
+{
+ RackPinionResult result;
+
+ // ASMT rack pinion joint must get the rack as I and pinion as J.
+ int slidingIndex = slidingPartIndex(joint);
+ if (slidingIndex == 0) {
+ return result;
+ }
+
+ if (slidingIndex != 1) {
+ swapJCS(joint); // make sure that rack is first.
+ }
+
+ App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
+ App::DocumentObject* obj1 = getObjFromJointRef(joint, "Reference1");
+ Base::Placement plc1 = getPlacementFromProp(joint, "Placement1");
+
+ App::DocumentObject* obj2 = getObjFromJointRef(joint, "Reference2");
+ Base::Placement plc2 = getPlacementFromProp(joint, "Placement2");
+
+ if (!part1 || !obj1) {
+ Base::Console().warning("Reference1 of Joint %s is bad.\n", joint->getFullName());
+ return result;
+ }
+
+ // Ensure parts are registered
+ result.partIdI = registerPart(part1);
+
+ // For the pinion — use standard marker computation
+ result.markerJ = computeMarkerTransform(joint, "Reference2", "Placement2");
+
+ App::DocumentObject* part2 = getMovingPartFromRef(joint, "Reference2");
+ if (part2) {
+ result.partIdJ = registerPart(part2);
+ }
+
+ // For the rack — need to adjust placement so X axis aligns with slider axis
+ auto* ref1 = dynamic_cast(joint->getPropertyByName("Reference1"));
+ auto* ref2 = dynamic_cast(joint->getPropertyByName("Reference2"));
+ if (!ref1 || !ref2) {
+ return result;
+ }
+
+ // Make the pinion plc relative to the rack placement
+ Base::Placement pinion_global_plc = getGlobalPlacement(obj2, ref2);
+ plc2 = pinion_global_plc * plc2;
+ Base::Placement rack_global_plc = getGlobalPlacement(obj1, ref1);
+ plc2 = rack_global_plc.inverse() * plc2;
+
+ // The rot of the rack placement should be the same as the pinion, but with X axis along the
+ // slider axis.
+ Base::Rotation rot = plc2.getRotation();
+ Base::Vector3d currentZAxis = rot.multVec(Base::Vector3d(0, 0, 1));
+ Base::Vector3d currentXAxis = rot.multVec(Base::Vector3d(1, 0, 0));
+ Base::Vector3d targetXAxis = plc1.getRotation().multVec(Base::Vector3d(0, 0, 1));
+
+ double yawAdjustment = currentXAxis.GetAngle(targetXAxis);
+
+ Base::Vector3d crossProd = currentXAxis.Cross(targetXAxis);
+ if (currentZAxis * crossProd < 0) {
+ yawAdjustment = -yawAdjustment;
+ }
+
+ Base::Rotation yawRotation(currentZAxis, yawAdjustment);
+ Base::Rotation adjustedRotation = rot * yawRotation;
+ plc1.setRotation(adjustedRotation);
+
+ // Transform to part-relative coordinates (same as handleOneSideOfJoint end logic)
+ if (obj1->getNameInDocument() != part1->getNameInDocument()) {
+ plc1 = rack_global_plc * plc1;
+ Base::Placement part_global_plc = getGlobalPlacement(part1, ref1);
+ plc1 = part_global_plc.inverse() * plc1;
+ }
+
+ // Apply bundle offset if present
+ auto idIt = objToPartId_.find(part1);
+ if (idIt != objToPartId_.end()) {
+ const auto& mappings = partIdToObjs_[idIt->second];
+ for (const auto& m : mappings) {
+ if (m.obj == part1 && !m.offset.isIdentity()) {
+ plc1 = m.offset * plc1;
+ break;
+ }
+ }
+ }
+
+ result.markerI = placementToTransform(plc1);
+
+ return result;
+}
+
+bool AssemblyObject::isJointValid(App::DocumentObject* joint)
+{
+ // When dragging, parts connected by fixed joints are bundled.
+ // A joint that references two parts in the same bundle is self-referential and must be skipped.
+ App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
+ App::DocumentObject* part2 = getMovingPartFromRef(joint, "Reference2");
+ if (!part1 || !part2) {
+ return false;
+ }
+
+ auto it1 = objToPartId_.find(part1);
+ auto it2 = objToPartId_.find(part2);
+ if (it1 != objToPartId_.end() && it2 != objToPartId_.end() && it1->second == it2->second) {
+ Base::Console().warning(
+ "Assembly: Ignoring joint (%s) because its parts are connected by a fixed "
+ "joint bundle. This joint is a conflicting or redundant constraint.\n",
+ joint->getFullLabel()
+ );
+ return false;
+ }
+ return true;
+}
+
+
+// ── Joint / Part graph helpers (unchanged) ─────────────────────────
+
App::DocumentObject* AssemblyObject::getJointOfPartConnectingToGround(
App::DocumentObject* part,
std::string& name,
@@ -952,50 +1709,6 @@ std::unordered_set AssemblyObject::getGroundedParts()
return groundedSet;
}
-std::unordered_set AssemblyObject::fixGroundedParts()
-{
- auto groundedParts = getGroundedParts();
-
- for (auto obj : groundedParts) {
- if (!obj) {
- continue;
- }
-
- Base::Placement plc = getPlacementFromProp(obj, "Placement");
- std::string str = obj->getFullName();
- fixGroundedPart(obj, plc, str);
- }
- return groundedParts;
-}
-
-void AssemblyObject::fixGroundedPart(App::DocumentObject* obj, Base::Placement& plc, std::string& name)
-{
- if (!obj) {
- return;
- }
-
- std::string markerName1 = "marker-" + obj->getFullName();
- auto mbdMarker1 = makeMbdMarker(markerName1, plc);
- mbdAssembly->addMarker(mbdMarker1);
-
- std::shared_ptr mbdPart = getMbDPart(obj);
-
- std::string markerName2 = "FixingMarker";
- Base::Placement basePlc = Base::Placement();
- auto mbdMarker2 = makeMbdMarker(markerName2, basePlc);
- mbdPart->addMarker(mbdMarker2);
-
- markerName1 = "/OndselAssembly/" + mbdMarker1->name;
- markerName2 = "/OndselAssembly/" + mbdPart->name + "/" + mbdMarker2->name;
-
- auto mbdJoint = CREATE::With();
- mbdJoint->setName(name);
- mbdJoint->setMarkerI(markerName1);
- mbdJoint->setMarkerJ(markerName2);
-
- mbdAssembly->addJoint(mbdJoint);
-}
-
bool AssemblyObject::isJointConnectingPartToGround(App::DocumentObject* joint, const char* propname)
{
if (!joint || !isJointTypeConnecting(joint)) {
@@ -1213,641 +1926,6 @@ bool AssemblyObject::isPartConnected(App::DocumentObject* obj)
return false;
}
-void AssemblyObject::jointParts(std::vector joints)
-{
- for (auto* joint : joints) {
- if (!joint) {
- continue;
- }
-
- std::vector> mbdJoints = makeMbdJoint(joint);
- for (auto& mbdJoint : mbdJoints) {
- mbdAssembly->addJoint(mbdJoint);
- }
- }
-}
-
-void Assembly::AssemblyObject::create_mbdSimulationParameters(App::DocumentObject* sim)
-{
- auto mbdSim = mbdAssembly->simulationParameters;
- if (!sim) {
- return;
- }
- auto valueOf = [](DocumentObject* docObj, const char* propName) {
- auto* prop = dynamic_cast(docObj->getPropertyByName(propName));
- if (!prop) {
- return 0.0;
- }
- return prop->getValue();
- };
- mbdSim->settstart(valueOf(sim, "aTimeStart"));
- mbdSim->settend(valueOf(sim, "bTimeEnd"));
- mbdSim->sethout(valueOf(sim, "cTimeStepOutput"));
- mbdSim->sethmin(1.0e-9);
- mbdSim->sethmax(1.0);
- mbdSim->seterrorTol(valueOf(sim, "fGlobalErrorTolerance"));
-}
-
-std::shared_ptr AssemblyObject::makeMbdJointOfType(App::DocumentObject* joint, JointType type)
-{
- switch (type) {
- case JointType::Fixed:
- if (bundleFixed) {
- return nullptr;
- }
- return CREATE::With();
-
- case JointType::Revolute:
- return CREATE::With();
-
- case JointType::Cylindrical:
- return CREATE::With();
-
- case JointType::Slider:
- return CREATE::With();
-
- case JointType::Ball:
- return CREATE::With();
-
- case JointType::Distance:
- return makeMbdJointDistance(joint);
-
- case JointType::Parallel:
- return CREATE::With();
-
- case JointType::Perpendicular:
- return CREATE::With();
-
- case JointType::Angle: {
- double angle = fabs(Base::toRadians(getJointAngle(joint)));
- if (fmod(angle, 2 * std::numbers::pi) < Precision::Confusion()) {
- return CREATE::With();
- }
- auto mbdJoint = CREATE::With();
- mbdJoint->theIzJz = angle;
- return mbdJoint;
- }
-
- case JointType::RackPinion: {
- auto mbdJoint = CREATE::With();
- mbdJoint->pitchRadius = getJointDistance(joint);
- return mbdJoint;
- }
-
- case JointType::Screw: {
- int slidingIndex = slidingPartIndex(joint);
- if (slidingIndex == 0) { // invalid this joint needs a slider
- return nullptr;
- }
-
- if (slidingIndex != 1) {
- swapJCS(joint); // make sure that sliding is first.
- }
-
- auto mbdJoint = CREATE::With();
- mbdJoint->pitch = getJointDistance(joint);
- return mbdJoint;
- }
-
- case JointType::Gears: {
- auto mbdJoint = CREATE::With();
- mbdJoint->radiusI = getJointDistance(joint);
- mbdJoint->radiusJ = getJointDistance2(joint);
- return mbdJoint;
- }
-
- case JointType::Belt: {
- auto mbdJoint = CREATE::With();
- mbdJoint->radiusI = getJointDistance(joint);
- mbdJoint->radiusJ = -getJointDistance2(joint);
- return mbdJoint;
- }
-
- default:
- return nullptr;
- }
-}
-
-std::shared_ptr AssemblyObject::makeMbdJointDistance(App::DocumentObject* joint)
-{
- DistanceType type = getDistanceType(joint);
-
- std::string elt1 = getElementFromProp(joint, "Reference1");
- std::string elt2 = getElementFromProp(joint, "Reference2");
- auto* obj1 = getLinkedObjFromRef(joint, "Reference1");
- auto* obj2 = getLinkedObjFromRef(joint, "Reference2");
-
- switch (type) {
- case DistanceType::PointPoint: {
- // Point to point distance, or ball joint if distance=0.
- double distance = getJointDistance(joint);
- if (distance < Precision::Confusion()) {
- return CREATE::With();
- }
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = distance;
- return mbdJoint;
- }
-
- // Edge - edge cases
- case DistanceType::LineLine: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint);
- return mbdJoint;
- }
-
- case DistanceType::LineCircle: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getEdgeRadius(obj2, elt2);
- return mbdJoint;
- }
-
- case DistanceType::CircleCircle: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getEdgeRadius(obj1, elt1)
- + getEdgeRadius(obj2, elt2);
- return mbdJoint;
- }
-
- // Face - Face cases
- case DistanceType::PlanePlane: {
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint);
- return mbdJoint;
- }
-
- case DistanceType::PlaneCylinder: {
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint) + getFaceRadius(obj2, elt2);
- return mbdJoint;
- }
-
- case DistanceType::PlaneSphere: {
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint) + getFaceRadius(obj2, elt2);
- return mbdJoint;
- }
-
- case DistanceType::PlaneTorus: {
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint);
- return mbdJoint;
- }
-
- case DistanceType::CylinderCylinder: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getFaceRadius(obj1, elt1)
- + getFaceRadius(obj2, elt2);
- return mbdJoint;
- }
-
- case DistanceType::CylinderSphere: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getFaceRadius(obj1, elt1)
- + getFaceRadius(obj2, elt2);
- return mbdJoint;
- }
-
- case DistanceType::CylinderTorus: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getFaceRadius(obj1, elt1)
- + getFaceRadius(obj2, elt2);
- return mbdJoint;
- }
-
- case DistanceType::TorusTorus: {
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint);
- return mbdJoint;
- }
-
- case DistanceType::TorusSphere: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getFaceRadius(obj1, elt1)
- + getFaceRadius(obj2, elt2);
- return mbdJoint;
- }
-
- case DistanceType::SphereSphere: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getFaceRadius(obj1, elt1)
- + getFaceRadius(obj2, elt2);
- return mbdJoint;
- }
-
- // Point - Face cases
- case DistanceType::PointPlane: {
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint);
- return mbdJoint;
- }
-
- case DistanceType::PointCylinder: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getFaceRadius(obj1, elt1);
- return mbdJoint;
- }
-
- case DistanceType::PointSphere: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint) + getFaceRadius(obj1, elt1);
- return mbdJoint;
- }
-
- // Edge - Face cases
- case DistanceType::LinePlane: {
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint);
- return mbdJoint;
- }
-
- // Point - Edge cases
- case DistanceType::PointLine: {
- auto mbdJoint = CREATE::With();
- mbdJoint->distanceIJ = getJointDistance(joint);
- return mbdJoint;
- }
-
- case DistanceType::PointCurve: {
- // For other curves we do a point in plane-of-the-curve.
- // Maybe it would be best tangent / distance to the conic?
- // For arcs and circles we could use ASMTRevSphJoint. But is it better than
- // pointInPlane?
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint);
- return mbdJoint;
- }
-
- default: {
- // by default we make a planar joint.
- auto mbdJoint = CREATE::With();
- mbdJoint->offset = getJointDistance(joint);
- return mbdJoint;
- }
- }
-}
-
-std::vector> AssemblyObject::makeMbdJoint(App::DocumentObject* joint)
-{
- if (!joint) {
- return {};
- }
-
- JointType jointType = getJointType(joint);
-
- std::shared_ptr mbdJoint = makeMbdJointOfType(joint, jointType);
- if (!mbdJoint || !isMbDJointValid(joint)) {
- return {};
- }
-
- std::string fullMarkerNameI, fullMarkerNameJ;
- if (jointType == JointType::RackPinion) {
- getRackPinionMarkers(joint, fullMarkerNameI, fullMarkerNameJ);
- }
- else {
- fullMarkerNameI = handleOneSideOfJoint(joint, "Reference1", "Placement1");
- fullMarkerNameJ = handleOneSideOfJoint(joint, "Reference2", "Placement2");
- }
- if (fullMarkerNameI == "" || fullMarkerNameJ == "") {
- return {};
- }
-
- mbdJoint->setName(joint->getFullName());
- mbdJoint->setMarkerI(fullMarkerNameI);
- mbdJoint->setMarkerJ(fullMarkerNameJ);
-
- // Add limits if needed. We do not add if this is a simulation or their might clash.
- if (motions.empty()) {
- if (jointType == JointType::Slider || jointType == JointType::Cylindrical) {
- auto* pLenMin = dynamic_cast(joint->getPropertyByName("LengthMin"));
- auto* pLenMax = dynamic_cast(joint->getPropertyByName("LengthMax"));
- auto* pMinEnabled = dynamic_cast(
- joint->getPropertyByName("EnableLengthMin")
- );
- auto* pMaxEnabled = dynamic_cast(
- joint->getPropertyByName("EnableLengthMax")
- );
-
- if (pLenMin && pLenMax && pMinEnabled && pMaxEnabled) { // Make sure properties do exist
- // Swap the values if necessary.
- bool minEnabled = pMinEnabled->getValue();
- bool maxEnabled = pMaxEnabled->getValue();
- double minLength = pLenMin->getValue();
- double maxLength = pLenMax->getValue();
-
- if ((minLength > maxLength) && minEnabled && maxEnabled) {
- pLenMin->setValue(maxLength);
- pLenMax->setValue(minLength);
- minLength = maxLength;
- maxLength = pLenMax->getValue();
-
- pMinEnabled->setValue(maxEnabled);
- pMaxEnabled->setValue(minEnabled);
- minEnabled = maxEnabled;
- maxEnabled = pMaxEnabled->getValue();
- }
-
- if (minEnabled) {
- auto limit = ASMTTranslationLimit::With();
- limit->setName(joint->getFullName() + "-LimitLenMin");
- limit->setMarkerI(fullMarkerNameI);
- limit->setMarkerJ(fullMarkerNameJ);
- limit->settype("=>");
- limit->setlimit(std::to_string(minLength));
- limit->settol("1.0e-9");
- mbdAssembly->addLimit(limit);
- }
-
- if (maxEnabled) {
- auto limit2 = ASMTTranslationLimit::With();
- limit2->setName(joint->getFullName() + "-LimitLenMax");
- limit2->setMarkerI(fullMarkerNameI);
- limit2->setMarkerJ(fullMarkerNameJ);
- limit2->settype("=<");
- limit2->setlimit(std::to_string(maxLength));
- limit2->settol("1.0e-9");
- mbdAssembly->addLimit(limit2);
- }
- }
- }
- if (jointType == JointType::Revolute || jointType == JointType::Cylindrical) {
- auto* pRotMin = dynamic_cast(joint->getPropertyByName("AngleMin"));
- auto* pRotMax = dynamic_cast(joint->getPropertyByName("AngleMax"));
- auto* pMinEnabled = dynamic_cast(
- joint->getPropertyByName("EnableAngleMin")
- );
- auto* pMaxEnabled = dynamic_cast(
- joint->getPropertyByName("EnableAngleMax")
- );
-
- if (pRotMin && pRotMax && pMinEnabled && pMaxEnabled) { // Make sure properties do exist
- // Swap the values if necessary.
- bool minEnabled = pMinEnabled->getValue();
- bool maxEnabled = pMaxEnabled->getValue();
- double minAngle = pRotMin->getValue();
- double maxAngle = pRotMax->getValue();
- if ((minAngle > maxAngle) && minEnabled && maxEnabled) {
- pRotMin->setValue(maxAngle);
- pRotMax->setValue(minAngle);
- minAngle = maxAngle;
- maxAngle = pRotMax->getValue();
-
- pMinEnabled->setValue(maxEnabled);
- pMaxEnabled->setValue(minEnabled);
- minEnabled = maxEnabled;
- maxEnabled = pMaxEnabled->getValue();
- }
-
- if (minEnabled) {
- auto limit = ASMTRotationLimit::With();
- limit->setName(joint->getFullName() + "-LimitRotMin");
- limit->setMarkerI(fullMarkerNameI);
- limit->setMarkerJ(fullMarkerNameJ);
- limit->settype("=>");
- limit->setlimit(std::to_string(minAngle) + "*pi/180.0");
- limit->settol("1.0e-9");
- mbdAssembly->addLimit(limit);
- }
-
- if (maxEnabled) {
- auto limit2 = ASMTRotationLimit::With();
- limit2->setName(joint->getFullName() + "-LimitRotMax");
- limit2->setMarkerI(fullMarkerNameI);
- limit2->setMarkerJ(fullMarkerNameJ);
- limit2->settype("=<");
- limit2->setlimit(std::to_string(maxAngle) + "*pi/180.0");
- limit2->settol("1.0e-9");
- mbdAssembly->addLimit(limit2);
- }
- }
- }
- }
- std::vector done;
- // Add motions if needed
- for (auto* motion : motions) {
- if (std::ranges::find(done, motion) != done.end()) {
- continue; // don't process twice (can happen in case of cylindrical)
- }
-
- auto* pJoint = dynamic_cast(motion->getPropertyByName("Joint"));
- if (!pJoint) {
- continue;
- }
- App::DocumentObject* motionJoint = pJoint->getValue();
- if (joint != motionJoint) {
- continue;
- }
-
- auto* pType = dynamic_cast(motion->getPropertyByName("MotionType"));
- auto* pFormula = dynamic_cast(motion->getPropertyByName("Formula"));
- if (!pType || !pFormula) {
- continue;
- }
- std::string formula = pFormula->getValue();
- if (formula == "") {
- continue;
- }
- std::string motionType = pType->getValueAsString();
-
- // check if there is a second motion as cylindrical can have both,
- // in which case the solver needs a general motion.
- for (auto* motion2 : motions) {
- pJoint = dynamic_cast(motion2->getPropertyByName("Joint"));
- if (!pJoint) {
- continue;
- }
- motionJoint = pJoint->getValue();
- if (joint != motionJoint || motion2 == motion) {
- continue;
- }
-
- auto* pType2 = dynamic_cast(
- motion2->getPropertyByName("MotionType")
- );
- auto* pFormula2 = dynamic_cast(motion2->getPropertyByName("Formula"));
- if (!pType2 || !pFormula2) {
- continue;
- }
- std::string formula2 = pFormula2->getValue();
- if (formula2 == "") {
- continue;
- }
- std::string motionType2 = pType2->getValueAsString();
- if (motionType2 == motionType) {
- continue; // only if both motions are different. ie one angular and one linear.
- }
-
- auto ASMTmotion = CREATE::With();
- ASMTmotion->setName(joint->getFullName() + "-ScrewMotion");
- ASMTmotion->setMarkerI(fullMarkerNameI);
- ASMTmotion->setMarkerJ(fullMarkerNameJ);
- ASMTmotion->rIJI->atiput(2, motionType == "Angular" ? formula2 : formula);
- ASMTmotion->angIJJ->atiput(2, motionType == "Angular" ? formula : formula2);
- mbdAssembly->addMotion(ASMTmotion);
-
- done.push_back(motion2);
- }
-
- if (motionType == "Angular") {
- auto ASMTmotion = CREATE::With();
- ASMTmotion->setName(joint->getFullName() + "-AngularMotion");
- ASMTmotion->setMarkerI(fullMarkerNameI);
- ASMTmotion->setMarkerJ(fullMarkerNameJ);
- ASMTmotion->setRotationZ(formula);
- mbdAssembly->addMotion(ASMTmotion);
- }
- else if (motionType == "Linear") {
- auto ASMTmotion = CREATE::With();
- ASMTmotion->setName(joint->getFullName() + "-LinearMotion");
- ASMTmotion->setMarkerI(fullMarkerNameI);
- ASMTmotion->setMarkerJ(fullMarkerNameJ);
- ASMTmotion->setTranslationZ(formula);
- mbdAssembly->addMotion(ASMTmotion);
- }
- }
-
- return {mbdJoint};
-}
-
-std::string AssemblyObject::handleOneSideOfJoint(
- App::DocumentObject* joint,
- const char* propRefName,
- const char* propPlcName
-)
-{
- App::DocumentObject* part = getMovingPartFromRef(joint, propRefName);
- App::DocumentObject* obj = getObjFromJointRef(joint, propRefName);
-
- if (!part || !obj) {
- Base::Console()
- .warning("The property %s of Joint %s is bad.\n", propRefName, joint->getFullName());
- return "";
- }
-
- MbDPartData data = getMbDData(part);
- std::shared_ptr mbdPart = data.part;
- Base::Placement plc = getPlacementFromProp(joint, propPlcName);
- // Now we have plc which is the JCS placement, but its relative to the Object, not to the
- // containing Part.
-
- if (obj->getNameInDocument() != part->getNameInDocument()) {
-
- auto* ref = dynamic_cast(joint->getPropertyByName(propRefName));
- if (!ref) {
- return "";
- }
-
- Base::Placement obj_global_plc = getGlobalPlacement(obj, ref);
- plc = obj_global_plc * plc;
-
- Base::Placement part_global_plc = getGlobalPlacement(part, ref);
- plc = part_global_plc.inverse() * plc;
- }
- // check if we need to add an offset in case of bundled parts.
- if (!data.offsetPlc.isIdentity()) {
- plc = data.offsetPlc * plc;
- }
-
- std::string markerName = joint->getFullName();
- auto mbdMarker = makeMbdMarker(markerName, plc);
- mbdPart->addMarker(mbdMarker);
-
- return "/OndselAssembly/" + mbdPart->name + "/" + markerName;
-}
-
-void AssemblyObject::getRackPinionMarkers(
- App::DocumentObject* joint,
- std::string& markerNameI,
- std::string& markerNameJ
-)
-{
- // ASMT rack pinion joint must get the rack as I and pinion as J.
- // - rack marker has to have Z axis parallel to pinion Z axis.
- // - rack marker has to have X axis parallel to the sliding axis.
- // The user will have selected the sliding marker so we need to transform it.
- // And we need to detect which marker is the rack.
-
- int slidingIndex = slidingPartIndex(joint);
- if (slidingIndex == 0) {
- return;
- }
-
- if (slidingIndex != 1) {
- swapJCS(joint); // make sure that rack is first.
- }
-
- App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
- App::DocumentObject* obj1 = getObjFromJointRef(joint, "Reference1");
- Base::Placement plc1 = getPlacementFromProp(joint, "Placement1");
-
- App::DocumentObject* obj2 = getObjFromJointRef(joint, "Reference2");
- Base::Placement plc2 = getPlacementFromProp(joint, "Placement2");
-
- if (!part1 || !obj1) {
- Base::Console().warning("Reference1 of Joint %s is bad.\n", joint->getFullName());
- return;
- }
-
- // For the pinion nothing special needed :
- markerNameJ = handleOneSideOfJoint(joint, "Reference2", "Placement2");
-
- // For the rack we need to change the placement :
- // make the pinion plc relative to the rack placement.
- auto* ref1 = dynamic_cast(joint->getPropertyByName("Reference1"));
- auto* ref2 = dynamic_cast(joint->getPropertyByName("Reference2"));
- if (!ref1 || !ref2) {
- return;
- }
- Base::Placement pinion_global_plc = getGlobalPlacement(obj2, ref2);
- plc2 = pinion_global_plc * plc2;
- Base::Placement rack_global_plc = getGlobalPlacement(obj1, ref1);
- plc2 = rack_global_plc.inverse() * plc2;
-
- // The rot of the rack placement should be the same as the pinion, but with X axis along the
- // slider axis.
- Base::Rotation rot = plc2.getRotation();
- // the yaw of rot has to be the same as plc1
- Base::Vector3d currentZAxis = rot.multVec(Base::Vector3d(0, 0, 1));
- Base::Vector3d currentXAxis = rot.multVec(Base::Vector3d(1, 0, 0));
- Base::Vector3d targetXAxis = plc1.getRotation().multVec(Base::Vector3d(0, 0, 1));
-
- // Calculate the angle between the current X axis and the target X axis
- double yawAdjustment = currentXAxis.GetAngle(targetXAxis);
-
- // Determine the direction of the yaw adjustment using cross product
- Base::Vector3d crossProd = currentXAxis.Cross(targetXAxis);
- if (currentZAxis * crossProd < 0) { // If cross product is in opposite direction to Z axis
- yawAdjustment = -yawAdjustment;
- }
-
- // Create a yaw rotation around the Z axis
- Base::Rotation yawRotation(currentZAxis, yawAdjustment);
-
- // Combine the initial rotation with the yaw adjustment
- Base::Rotation adjustedRotation = rot * yawRotation;
- plc1.setRotation(adjustedRotation);
-
- // Then end of processing similar to handleOneSideOfJoint :
- MbDPartData data1 = getMbDData(part1);
- std::shared_ptr mbdPart = data1.part;
- if (obj1->getNameInDocument() != part1->getNameInDocument()) {
- plc1 = rack_global_plc * plc1;
-
- Base::Placement part_global_plc = getGlobalPlacement(part1, ref1);
- plc1 = part_global_plc.inverse() * plc1;
- }
- // check if we need to add an offset in case of bundled parts.
- if (!data1.offsetPlc.isIdentity()) {
- plc1 = data1.offsetPlc * plc1;
- }
-
- std::string markerName = joint->getFullName();
- auto mbdMarker = makeMbdMarker(markerName, plc1);
- mbdPart->addMarker(mbdMarker);
-
- markerNameI = "/OndselAssembly/" + mbdPart->name + "/" + markerName;
-}
-
int AssemblyObject::slidingPartIndex(App::DocumentObject* joint)
{
App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
@@ -1879,8 +1957,6 @@ int AssemblyObject::slidingPartIndex(App::DocumentObject* joint)
}
if (found != 0) {
- // check the placements plcjt and (jcs1 or jcs2 depending on found value) Z axis are
- // colinear ie if their pitch and roll are the same.
double y1, p1, r1, y2, p2, r2;
plcjt.getRotation().getYawPitchRoll(y1, p1, r1);
plci.getRotation().getYawPitchRoll(y2, p2, r2);
@@ -1893,130 +1969,6 @@ int AssemblyObject::slidingPartIndex(App::DocumentObject* joint)
return slidingFound;
}
-bool AssemblyObject::isMbDJointValid(App::DocumentObject* joint)
-{
- // When dragging a part, we are bundling fixed parts together.
- // This may lead to a conflicting joint that is self referencing a MbD part.
- // The solver crash when fed such a bad joint. So we make sure it does not happen.
- App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
- App::DocumentObject* part2 = getMovingPartFromRef(joint, "Reference2");
- if (!part1 || !part2) {
- return false;
- }
-
- // If this joint is self-referential it must be ignored.
- if (getMbDPart(part1) == getMbDPart(part2)) {
- Base::Console().warning(
- "Assembly: Ignoring joint (%s) because its parts are connected by a fixed "
- "joint bundle. This joint is a conflicting or redundant constraint.\n",
- joint->getFullLabel()
- );
- return false;
- }
- return true;
-}
-
-AssemblyObject::MbDPartData AssemblyObject::getMbDData(App::DocumentObject* part)
-{
- auto it = objectPartMap.find(part);
- if (it != objectPartMap.end()) {
- // part has been associated with an ASMTPart before
- return it->second;
- }
-
- // part has not been associated with an ASMTPart before
- std::string str = part->getFullName();
- Base::Placement plc = getPlacementFromProp(part, "Placement");
- std::shared_ptr mbdPart = makeMbdPart(str, plc);
- mbdAssembly->addPart(mbdPart);
- MbDPartData data = {mbdPart, Base::Placement()};
- objectPartMap[part] = data; // Store the association
-
- // Associate other objects connected with fixed joints
- if (bundleFixed) {
- auto addConnectedFixedParts = [&](App::DocumentObject* currentPart, auto& self) -> void {
- std::vector joints = getJointsOfPart(currentPart);
- for (auto* joint : joints) {
- JointType jointType = getJointType(joint);
- if (jointType == JointType::Fixed) {
- App::DocumentObject* part1 = getMovingPartFromRef(joint, "Reference1");
- App::DocumentObject* part2 = getMovingPartFromRef(joint, "Reference2");
- App::DocumentObject* partToAdd = currentPart == part1 ? part2 : part1;
-
- if (objectPartMap.find(partToAdd) != objectPartMap.end()) {
- // already added
- continue;
- }
-
- Base::Placement plci = getPlacementFromProp(partToAdd, "Placement");
- MbDPartData partData = {mbdPart, plc.inverse() * plci};
- objectPartMap[partToAdd] = partData; // Store the association
-
- // Recursively call for partToAdd
- self(partToAdd, self);
- }
- }
- };
-
- addConnectedFixedParts(part, addConnectedFixedParts);
- }
- return data;
-}
-
-std::shared_ptr AssemblyObject::getMbDPart(App::DocumentObject* part)
-{
- if (!part) {
- return nullptr;
- }
- return getMbDData(part).part;
-}
-
-std::shared_ptr AssemblyObject::makeMbdPart(std::string& name, Base::Placement plc, double mass)
-{
- auto mbdPart = CREATE::With();
- mbdPart->setName(name);
-
- auto massMarker = CREATE::With();
- massMarker->setMass(mass);
- massMarker->setDensity(1.0);
- massMarker->setMomentOfInertias(1.0, 1.0, 1.0);
- mbdPart->setPrincipalMassMarker(massMarker);
-
- Base::Vector3d pos = plc.getPosition();
- mbdPart->setPosition3D(pos.x, pos.y, pos.z);
-
- // TODO : replace with quaternion to simplify
- Base::Rotation rot = plc.getRotation();
- Base::Matrix4D mat;
- rot.getValue(mat);
- Base::Vector3d r0 = mat.getRow(0);
- Base::Vector3d r1 = mat.getRow(1);
- Base::Vector3d r2 = mat.getRow(2);
- mbdPart->setRotationMatrix(r0.x, r0.y, r0.z, r1.x, r1.y, r1.z, r2.x, r2.y, r2.z);
-
- return mbdPart;
-}
-
-std::shared_ptr AssemblyObject::makeMbdMarker(std::string& name, Base::Placement& plc)
-{
- auto mbdMarker = CREATE::With();
- mbdMarker->setName(name);
-
- Base::Vector3d pos = plc.getPosition();
- mbdMarker->setPosition3D(pos.x, pos.y, pos.z);
-
- // TODO : replace with quaternion to simplify
- Base::Rotation rot = plc.getRotation();
- Base::Matrix4D mat;
- rot.getValue(mat);
- Base::Vector3d r0 = mat.getRow(0);
- Base::Vector3d r1 = mat.getRow(1);
- Base::Vector3d r2 = mat.getRow(2);
- mbdMarker->setRotationMatrix(r0.x, r0.y, r0.z, r1.x, r1.y, r1.z, r2.x, r2.y, r2.z);
-
- return mbdMarker;
-}
-
std::vector AssemblyObject::getDownstreamParts(
App::DocumentObject* part,
App::DocumentObject* joint
diff --git a/src/Mod/Assembly/App/AssemblyObject.h b/src/Mod/Assembly/App/AssemblyObject.h
index 74bc8e64d5..cffe0d2483 100644
--- a/src/Mod/Assembly/App/AssemblyObject.h
+++ b/src/Mod/Assembly/App/AssemblyObject.h
@@ -25,24 +25,21 @@
#ifndef ASSEMBLY_AssemblyObject_H
#define ASSEMBLY_AssemblyObject_H
+#include
+
#include
#include
+#include
#include
#include
#include
-#include
-
-namespace MbD
+namespace KCSolve
{
-class ASMTPart;
-class ASMTAssembly;
-class ASMTJoint;
-class ASMTMarker;
-class ASMTPart;
-} // namespace MbD
+class IKCSolver;
+} // namespace KCSolve
namespace App
{
@@ -105,7 +102,6 @@ public:
void exportAsASMT(std::string fileName);
- Base::Placement getMbdPlacement(std::shared_ptr mbdPart);
bool validateNewPlacements();
void setNewPlacements();
static void redrawJointPlacements(std::vector joints);
@@ -114,42 +110,8 @@ public:
// This makes sure that LinkGroups or sub-assemblies have identity placements.
void ensureIdentityPlacements();
- // Ondsel Solver interface
- std::shared_ptr makeMbdAssembly();
- void create_mbdSimulationParameters(App::DocumentObject* sim);
- std::shared_ptr makeMbdPart(
- std::string& name,
- Base::Placement plc = Base::Placement(),
- double mass = 1.0
- );
- std::shared_ptr getMbDPart(App::DocumentObject* obj);
- // To help the solver, during dragging, we are bundling parts connected by a fixed joint.
- // So several assembly components are bundled in a single ASMTPart.
- // So we need to store the plc of each bundled object relative to the bundle origin (first obj
- // of objectPartMap).
- struct MbDPartData
- {
- std::shared_ptr part;
- Base::Placement offsetPlc; // This is the offset within the bundled parts
- };
- MbDPartData getMbDData(App::DocumentObject* part);
- std::shared_ptr makeMbdMarker(std::string& name, Base::Placement& plc);
- std::vector> makeMbdJoint(App::DocumentObject* joint);
- std::shared_ptr makeMbdJointOfType(App::DocumentObject* joint, JointType jointType);
- std::shared_ptr makeMbdJointDistance(App::DocumentObject* joint);
- std::string handleOneSideOfJoint(
- App::DocumentObject* joint,
- const char* propRefName,
- const char* propPlcName
- );
- void getRackPinionMarkers(
- App::DocumentObject* joint,
- std::string& markerNameI,
- std::string& markerNameJ
- );
int slidingPartIndex(App::DocumentObject* joint);
- void jointParts(std::vector joints);
JointGroup* getJointGroup() const;
ViewGroup* getExplodedViewGroup() const;
template
@@ -169,8 +131,6 @@ public:
const std::vector& excludeJoints = {}
);
std::unordered_set getGroundedParts();
- std::unordered_set fixGroundedParts();
- void fixGroundedPart(App::DocumentObject* obj, Base::Placement& plc, std::string& jointName);
bool isJointConnectingPartToGround(App::DocumentObject* joint, const char* partPropName);
bool isJointTypeConnecting(App::DocumentObject* joint);
@@ -210,7 +170,7 @@ public:
std::vector getMotionsFromSimulation(App::DocumentObject* sim);
- bool isMbDJointValid(App::DocumentObject* joint);
+ bool isJointValid(App::DocumentObject* joint);
bool isEmpty() const;
int numberOfComponents() const;
@@ -259,12 +219,56 @@ public:
fastsignals::signal signalSolverUpdate;
private:
- std::shared_ptr mbdAssembly;
+ // ── Solver integration ─────────────────────────────────────────
+
+ KCSolve::IKCSolver* getOrCreateSolver();
+
+ KCSolve::SolveContext buildSolveContext(
+ const std::vector& joints,
+ bool forSimulation = false,
+ App::DocumentObject* sim = nullptr
+ );
+
+ KCSolve::Transform computeMarkerTransform(
+ App::DocumentObject* joint,
+ const char* propRefName,
+ const char* propPlcName
+ );
+
+ struct RackPinionResult
+ {
+ std::string partIdI;
+ KCSolve::Transform markerI;
+ std::string partIdJ;
+ KCSolve::Transform markerJ;
+ };
+ RackPinionResult computeRackPinionMarkers(App::DocumentObject* joint);
+
+ // ── Part ↔ solver ID mapping ───────────────────────────────────
+
+ // Maps a solver part ID to the FreeCAD objects it represents.
+ // Multiple objects map to one ID when parts are bundled by Fixed joints.
+ struct PartMapping
+ {
+ App::DocumentObject* obj;
+ Base::Placement offset; // identity for primary, non-identity for bundled
+ };
+ std::unordered_map> partIdToObjs_;
+ std::unordered_map objToPartId_;
+
+ // Register a part (and recursively its fixed-joint bundle when bundleFixed is set).
+ // Returns the solver part ID.
+ std::string registerPart(App::DocumentObject* obj);
+
+ // ── Solver state ───────────────────────────────────────────────
+
+ std::unique_ptr solver_;
+ KCSolve::SolveResult lastResult_;
+
+ // ── Existing state (unchanged) ─────────────────────────────────
- std::unordered_map objectPartMap;
std::vector> objMasses;
std::vector draggedParts;
- std::vector motions;
std::vector> previousPositions;
diff --git a/src/Mod/Assembly/App/CMakeLists.txt b/src/Mod/Assembly/App/CMakeLists.txt
index 87053222f0..0bff518aec 100644
--- a/src/Mod/Assembly/App/CMakeLists.txt
+++ b/src/Mod/Assembly/App/CMakeLists.txt
@@ -5,7 +5,7 @@ set(Assembly_LIBS
PartDesign
Spreadsheet
FreeCADApp
- OndselSolver
+ KCSolve
)
generate_from_py(AssemblyObject)
diff --git a/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py
new file mode 100644
index 0000000000..3073197773
--- /dev/null
+++ b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py
@@ -0,0 +1,520 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# ***************************************************************************
+# * *
+# * Copyright (c) 2025 Kindred Systems *
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+"""Unit tests for the kcsolve pybind11 module."""
+
+import unittest
+
+
+class TestKCSolveImport(unittest.TestCase):
+ """Verify that the kcsolve module loads and exposes expected symbols."""
+
+ def test_import(self):
+ import kcsolve
+
+ for sym in (
+ "IKCSolver",
+ "OndselAdapter",
+ "Transform",
+ "Part",
+ "Constraint",
+ "SolveContext",
+ "SolveResult",
+ "BaseJointKind",
+ "SolveStatus",
+ "available",
+ "load",
+ "register_solver",
+ ):
+ self.assertTrue(hasattr(kcsolve, sym), f"missing symbol: {sym}")
+
+ def test_api_version(self):
+ import kcsolve
+
+ self.assertEqual(kcsolve.API_VERSION_MAJOR, 1)
+
+
+class TestKCSolveTypes(unittest.TestCase):
+ """Verify struct/enum bindings behave correctly."""
+
+ def test_transform_identity(self):
+ import kcsolve
+
+ t = kcsolve.Transform.identity()
+ self.assertEqual(list(t.position), [0.0, 0.0, 0.0])
+ self.assertEqual(list(t.quaternion), [1.0, 0.0, 0.0, 0.0]) # w,x,y,z
+
+ def test_part_defaults(self):
+ import kcsolve
+
+ p = kcsolve.Part()
+ self.assertEqual(p.id, "")
+ self.assertAlmostEqual(p.mass, 1.0)
+ self.assertFalse(p.grounded)
+
+ def test_solve_context_construction(self):
+ import kcsolve
+
+ ctx = kcsolve.SolveContext()
+ self.assertEqual(len(ctx.parts), 0)
+ self.assertEqual(len(ctx.constraints), 0)
+
+ p = kcsolve.Part()
+ p.id = "part1"
+ # pybind11 def_readwrite on std::vector returns a copy,
+ # so we must assign the whole list back.
+ ctx.parts = [p]
+ self.assertEqual(len(ctx.parts), 1)
+ self.assertEqual(ctx.parts[0].id, "part1")
+
+ def test_enum_values(self):
+ import kcsolve
+
+ self.assertEqual(int(kcsolve.SolveStatus.Success), 0)
+ # BaseJointKind.Fixed should exist
+ self.assertIsNotNone(kcsolve.BaseJointKind.Fixed)
+ # DiagnosticKind should exist
+ self.assertIsNotNone(kcsolve.DiagnosticKind.Redundant)
+
+ def test_constraint_fields(self):
+ import kcsolve
+
+ c = kcsolve.Constraint()
+ c.id = "Joint001"
+ c.part_i = "part1"
+ c.part_j = "part2"
+ c.type = kcsolve.BaseJointKind.Fixed
+ self.assertEqual(c.id, "Joint001")
+ self.assertEqual(c.type, kcsolve.BaseJointKind.Fixed)
+
+ def test_solve_result_fields(self):
+ import kcsolve
+
+ r = kcsolve.SolveResult()
+ self.assertEqual(r.status, kcsolve.SolveStatus.Success)
+ self.assertEqual(r.dof, -1)
+ self.assertEqual(len(r.placements), 0)
+
+
+class TestKCSolveRegistry(unittest.TestCase):
+ """Verify SolverRegistry wrapper functions."""
+
+ def test_available_returns_list(self):
+ import kcsolve
+
+ result = kcsolve.available()
+ self.assertIsInstance(result, list)
+
+ def test_load_ondsel(self):
+ import kcsolve
+
+ solver = kcsolve.load("ondsel")
+ # Ondsel should be registered by FreeCAD init
+ if solver is not None:
+ self.assertIn("Ondsel", solver.name())
+
+ def test_load_unknown_returns_none(self):
+ import kcsolve
+
+ solver = kcsolve.load("nonexistent_solver_xyz")
+ self.assertIsNone(solver)
+
+ def test_get_set_default(self):
+ import kcsolve
+
+ original = kcsolve.get_default()
+ # Setting unknown solver should return False
+ self.assertFalse(kcsolve.set_default("nonexistent_solver_xyz"))
+ # Default should be unchanged
+ 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."""
+
+ def _make_solver_class(self):
+ import kcsolve
+
+ class _DummySolver(kcsolve.IKCSolver):
+ def name(self):
+ return "DummyPySolver"
+
+ def supported_joints(self):
+ return [
+ kcsolve.BaseJointKind.Fixed,
+ kcsolve.BaseJointKind.Revolute,
+ ]
+
+ def solve(self, ctx):
+ r = kcsolve.SolveResult()
+ r.status = kcsolve.SolveStatus.Success
+ parts = ctx.parts # copy from C++ vector
+ r.dof = len(parts) * 6
+ placements = []
+ for p in parts:
+ pr = kcsolve.SolveResult.PartResult()
+ pr.id = p.id
+ pr.placement = p.placement
+ placements.append(pr)
+ r.placements = placements
+ return r
+
+ return _DummySolver
+
+ def test_instantiate_python_solver(self):
+ cls = self._make_solver_class()
+ solver = cls()
+ self.assertEqual(solver.name(), "DummyPySolver")
+ self.assertEqual(len(solver.supported_joints()), 2)
+
+ def test_python_solver_solve(self):
+ import kcsolve
+
+ cls = self._make_solver_class()
+ solver = cls()
+
+ ctx = kcsolve.SolveContext()
+ p = kcsolve.Part()
+ p.id = "box1"
+ p.grounded = True
+ ctx.parts = [p]
+
+ result = solver.solve(ctx)
+ self.assertEqual(result.status, kcsolve.SolveStatus.Success)
+ self.assertEqual(result.dof, 6)
+ self.assertEqual(len(result.placements), 1)
+ self.assertEqual(result.placements[0].id, "box1")
+
+ def test_register_and_roundtrip(self):
+ import kcsolve
+
+ cls = self._make_solver_class()
+ # Use a unique name to avoid collision across test runs
+ name = "test_dummy_roundtrip"
+ kcsolve.register_solver(name, cls)
+
+ self.assertIn(name, kcsolve.available())
+
+ loaded = kcsolve.load(name)
+ self.assertIsNotNone(loaded)
+ self.assertEqual(loaded.name(), "DummyPySolver")
+
+ ctx = kcsolve.SolveContext()
+ result = loaded.solve(ctx)
+ self.assertEqual(result.status, kcsolve.SolveStatus.Success)
+
+ def test_default_virtuals(self):
+ """Default implementations of optional virtuals should not crash."""
+ import kcsolve
+
+ cls = self._make_solver_class()
+ solver = cls()
+ self.assertTrue(solver.is_deterministic())
+ self.assertFalse(solver.supports_bundle_fixed())
+
+ ctx = kcsolve.SolveContext()
+ diags = solver.diagnose(ctx)
+ self.assertEqual(len(diags), 0)
diff --git a/src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py b/src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py
new file mode 100644
index 0000000000..7062876e74
--- /dev/null
+++ b/src/Mod/Assembly/AssemblyTests/TestSolverIntegration.py
@@ -0,0 +1,216 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+# /****************************************************************************
+# *
+# Copyright (c) 2025 Kindred Systems *
+# *
+# 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 *
+# . *
+# *
+# ***************************************************************************/
+
+"""
+Solver integration tests for Phase 1e (KCSolve refactor).
+
+These tests verify that the AssemblyObject → IKCSolver → OndselAdapter pipeline
+produces correct results via the full FreeCAD stack. They complement the C++
+unit tests in tests/src/Mod/Assembly/Solver/.
+"""
+
+import os
+import tempfile
+import unittest
+
+import FreeCAD as App
+import JointObject
+
+
+class TestSolverIntegration(unittest.TestCase):
+ """Full-stack solver regression tests exercising AssemblyObject.solve()."""
+
+ 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 _make_box(self, x=0, y=0, z=0, size=10):
+ """Create a Part::Box inside the assembly with a given offset."""
+ 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):
+ """Create a grounded joint for the given object."""
+ gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint")
+ JointObject.GroundedJoint(gnd, obj)
+ return gnd
+
+ def _make_joint(self, joint_type, ref1, ref2):
+ """Create a joint of the given type connecting two (obj, subelements) pairs.
+
+ joint_type: integer JointType enum value (0=Fixed, 1=Revolute, etc.)
+ ref1, ref2: tuples of (obj, [sub_element, sub_element])
+ """
+ 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)
+
+ # Fixed joint (type 0) connecting Face6+Vertex7 on each box.
+ self._make_joint(
+ 0,
+ [box2, ["Face6", "Vertex7"]],
+ [box1, ["Face6", "Vertex7"]],
+ )
+
+ # After setJointConnectors, solve() was already called internally.
+ # Verify that box1 moved to match box2.
+ 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 (return 0)."""
+ box1 = self._make_box(0, 0, 0)
+ box2 = self._make_box(100, 0, 0)
+ self._ground(box1)
+
+ # Revolute joint (type 1) connecting Face6+Vertex7.
+ 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)
+
+ # Fixed joint but no ground.
+ 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_returns_redundancy(self):
+ """Over-constrained assembly → solve returns -2 (redundant)."""
+ box1 = self._make_box(0, 0, 0)
+ box2 = self._make_box(50, 0, 0)
+ self._ground(box1)
+
+ # Two fixed joints between the same faces → redundant.
+ self._make_joint(
+ 0,
+ [box1, ["Face6", "Vertex7"]],
+ [box2, ["Face6", "Vertex7"]],
+ )
+ self._make_joint(
+ 0,
+ [box1, ["Face5", "Vertex5"]],
+ [box2, ["Face5", "Vertex5"]],
+ )
+
+ result = self.assembly.solve()
+ self.assertEqual(result, -2, "Redundant constraints should return -2")
+
+ def test_export_asmt(self):
+ """exportAsASMT() produces a non-empty file."""
+ box1 = self._make_box(0, 0, 0)
+ box2 = self._make_box(50, 0, 0)
+ self._ground(box1)
+
+ self._make_joint(
+ 0,
+ [box1, ["Face6", "Vertex7"]],
+ [box2, ["Face6", "Vertex7"]],
+ )
+
+ self.assembly.solve()
+
+ with tempfile.NamedTemporaryFile(suffix=".asmt", delete=False) as f:
+ path = f.name
+
+ try:
+ self.assembly.exportAsASMT(path)
+ self.assertTrue(os.path.exists(path), "ASMT file should exist")
+ self.assertGreater(
+ os.path.getsize(path), 0, "ASMT file should be non-empty"
+ )
+ finally:
+ if os.path.exists(path):
+ os.unlink(path)
+
+ def test_solve_multiple_times_stable(self):
+ """Solving the same assembly 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",
+ )
diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt
index f79b42cb35..b8ceb8a5f5 100644
--- a/src/Mod/Assembly/CMakeLists.txt
+++ b/src/Mod/Assembly/CMakeLists.txt
@@ -11,6 +11,7 @@ else ()
endif ()
endif ()
+add_subdirectory(Solver)
add_subdirectory(App)
if(BUILD_GUI)
@@ -56,6 +57,8 @@ SET(AssemblyTests_SRCS
AssemblyTests/__init__.py
AssemblyTests/TestCore.py
AssemblyTests/TestCommandInsertLink.py
+ AssemblyTests/TestSolverIntegration.py
+ AssemblyTests/TestKCSolvePy.py
AssemblyTests/mocks/__init__.py
AssemblyTests/mocks/MockGui.py
)
diff --git a/src/Mod/Assembly/Solver/CMakeLists.txt b/src/Mod/Assembly/Solver/CMakeLists.txt
new file mode 100644
index 0000000000..2228f79c23
--- /dev/null
+++ b/src/Mod/Assembly/Solver/CMakeLists.txt
@@ -0,0 +1,46 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+set(KCSolve_SRCS
+ KCSolveGlobal.h
+ Types.h
+ IKCSolver.h
+ SolverRegistry.h
+ SolverRegistry.cpp
+ OndselAdapter.h
+ OndselAdapter.cpp
+)
+
+add_library(KCSolve SHARED ${KCSolve_SRCS})
+
+target_include_directories(KCSolve
+ PUBLIC
+ ${CMAKE_SOURCE_DIR}/src
+ ${CMAKE_BINARY_DIR}/src
+)
+
+target_compile_definitions(KCSolve
+ PRIVATE
+ CMAKE_INSTALL_PREFIX="${CMAKE_INSTALL_PREFIX}"
+)
+
+target_link_libraries(KCSolve
+ PRIVATE
+ FreeCADBase
+ OndselSolver
+)
+
+# Platform-specific dynamic loading library
+if(NOT WIN32)
+ target_link_libraries(KCSolve PRIVATE ${CMAKE_DL_LIBS})
+endif()
+
+if(FREECAD_WARN_ERROR)
+ target_compile_warn_error(KCSolve)
+endif()
+
+SET_BIN_DIR(KCSolve KCSolve /Mod/Assembly)
+INSTALL(TARGETS KCSolve DESTINATION ${CMAKE_INSTALL_LIBDIR})
+
+if(FREECAD_USE_PYBIND11)
+ add_subdirectory(bindings)
+endif()
diff --git a/src/Mod/Assembly/Solver/IKCSolver.h b/src/Mod/Assembly/Solver/IKCSolver.h
new file mode 100644
index 0000000000..4a404d134b
--- /dev/null
+++ b/src/Mod/Assembly/Solver/IKCSolver.h
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: LGPL-2.1-or-later
+/****************************************************************************
+ * *
+ * Copyright (c) 2025 Kindred Systems *
+ * *
+ * 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 *
+ * . *
+ * *
+ ***************************************************************************/
+
+#ifndef KCSOLVE_IKCSOLVER_H
+#define KCSOLVE_IKCSOLVER_H
+
+#include
+#include
+#include
+
+#include "Types.h"
+
+namespace KCSolve
+{
+
+/// Abstract interface for a pluggable assembly constraint solver.
+///
+/// Solver backends implement this interface. The Assembly module calls
+/// through it via the SolverRegistry. A minimal solver only needs to
+/// implement solve(), name(), and supported_joints() — all other methods
+/// have default implementations that either delegate to solve() or
+/// return sensible defaults.
+///
+/// Method mapping to current AssemblyObject operations:
+///
+/// solve() <-> AssemblyObject::solve()
+/// pre_drag() <-> AssemblyObject::preDrag()
+/// drag_step() <-> AssemblyObject::doDragStep()
+/// post_drag() <-> AssemblyObject::postDrag()
+/// run_kinematic() <-> AssemblyObject::generateSimulation()
+/// num_frames() <-> AssemblyObject::numberOfFrames()
+/// update_for_frame() <-> AssemblyObject::updateForFrame()
+/// diagnose() <-> AssemblyObject::updateSolveStatus()
+
+class IKCSolver
+{
+public:
+ virtual ~IKCSolver() = default;
+
+ /// Human-readable solver name (e.g. "OndselSolver (Lagrangian)").
+ virtual std::string name() const = 0;
+
+ /// Return the set of BaseJointKind values this solver supports.
+ /// The registry uses this for capability-based solver selection.
+ virtual std::vector supported_joints() const = 0;
+
+ // ── Static solve ───────────────────────────────────────────────
+
+ /// Solve the assembly for static equilibrium.
+ /// @param ctx Complete description of parts, constraints, and options.
+ /// @return Result with updated placements and diagnostics.
+ virtual SolveResult solve(const SolveContext& ctx) = 0;
+
+ /// Incrementally update an already-solved assembly after parameter
+ /// changes (e.g. joint angle/distance changed during joint creation).
+ /// Default: delegates to solve().
+ virtual SolveResult update(const SolveContext& ctx)
+ {
+ return solve(ctx);
+ }
+
+ // ── Interactive drag ───────────────────────────────────────────
+ //
+ // Three-phase protocol for interactive part dragging:
+ // 1. pre_drag() — solve initial state, prepare for dragging
+ // 2. drag_step() — called on each mouse move with updated positions
+ // 3. post_drag() — finalize and release internal solver state
+ //
+ // Solvers can maintain internal state across the drag session for
+ // better interactive performance. This addresses a known weakness
+ // in the current direct-OndselSolver integration.
+
+ /// Prepare for an interactive drag session.
+ /// @param ctx Assembly state before dragging begins.
+ /// @param drag_parts IDs of parts being dragged.
+ /// @return Initial solve result.
+ virtual SolveResult pre_drag(const SolveContext& ctx,
+ const std::vector& /*drag_parts*/)
+ {
+ return solve(ctx);
+ }
+
+ /// Perform one incremental drag step.
+ /// @param drag_placements Current placements of the dragged parts
+ /// (part ID + new transform).
+ /// @return Updated placements for all affected parts.
+ virtual SolveResult drag_step(
+ const std::vector& /*drag_placements*/)
+ {
+ return SolveResult {SolveStatus::Success, {}, -1, {}, 0};
+ }
+
+ /// End an interactive drag session and finalize state.
+ virtual void post_drag()
+ {
+ }
+
+ // ── Kinematic simulation ───────────────────────────────────────
+
+ /// Run a kinematic simulation over the time range in ctx.simulation.
+ /// After this call, num_frames() returns the frame count and
+ /// update_for_frame(i) retrieves individual frame placements.
+ /// Default: delegates to solve() (ignoring simulation params).
+ virtual SolveResult run_kinematic(const SolveContext& /*ctx*/)
+ {
+ return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
+ }
+
+ /// Number of simulation frames available after run_kinematic().
+ virtual std::size_t num_frames() const
+ {
+ return 0;
+ }
+
+ /// Retrieve part placements for simulation frame at index.
+ /// @pre index < num_frames()
+ virtual SolveResult update_for_frame(std::size_t /*index*/)
+ {
+ return SolveResult {SolveStatus::Failed, {}, -1, {}, 0};
+ }
+
+ // ── Diagnostics ────────────────────────────────────────────────
+
+ /// Analyze the assembly for redundant, conflicting, or malformed
+ /// constraints. May require a prior solve() call for some solvers.
+ virtual std::vector diagnose(const SolveContext& /*ctx*/)
+ {
+ return {};
+ }
+
+ // ── Capability queries ─────────────────────────────────────────
+
+ /// Whether this solver produces deterministic results given
+ /// identical input.
+ virtual bool is_deterministic() const
+ {
+ return true;
+ }
+
+ /// Export solver-native debug/diagnostic file (e.g. ASMT for OndselSolver).
+ /// Default: no-op. Requires a prior solve() or run_kinematic() call.
+ virtual void export_native(const std::string& /*path*/)
+ {
+ }
+
+ /// Whether this solver handles fixed-joint part bundling internally.
+ /// When false, the caller bundles parts connected by Fixed joints
+ /// before building the SolveContext. When true, the solver receives
+ /// unbundled parts and optimizes internally.
+ virtual bool supports_bundle_fixed() const
+ {
+ return false;
+ }
+
+ // Public default constructor for pybind11 trampoline support.
+ // The class remains abstract (3 pure virtuals prevent direct instantiation).
+ IKCSolver() = default;
+
+private:
+ // Non-copyable, non-movable (polymorphic base class)
+ IKCSolver(const IKCSolver&) = delete;
+ IKCSolver& operator=(const IKCSolver&) = delete;
+ IKCSolver(IKCSolver&&) = delete;
+ IKCSolver& operator=(IKCSolver&&) = delete;
+};
+
+} // namespace KCSolve
+
+#endif // KCSOLVE_IKCSOLVER_H
diff --git a/src/Mod/Assembly/Solver/KCSolveGlobal.h b/src/Mod/Assembly/Solver/KCSolveGlobal.h
new file mode 100644
index 0000000000..4985ef6934
--- /dev/null
+++ b/src/Mod/Assembly/Solver/KCSolveGlobal.h
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: LGPL-2.1-or-later
+/****************************************************************************
+ * *
+ * Copyright (c) 2025 Kindred Systems *
+ * *
+ * 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 *
+ * . *
+ * *
+ ***************************************************************************/
+
+#include
+
+#ifndef KCSOLVE_GLOBAL_H
+#define KCSOLVE_GLOBAL_H
+
+#ifndef KCSolveExport
+# ifdef KCSolve_EXPORTS
+# define KCSolveExport FREECAD_DECL_EXPORT
+# else
+# define KCSolveExport FREECAD_DECL_IMPORT
+# endif
+#endif
+
+#endif // KCSOLVE_GLOBAL_H
diff --git a/src/Mod/Assembly/Solver/OndselAdapter.cpp b/src/Mod/Assembly/Solver/OndselAdapter.cpp
new file mode 100644
index 0000000000..a2c58db8f0
--- /dev/null
+++ b/src/Mod/Assembly/Solver/OndselAdapter.cpp
@@ -0,0 +1,796 @@
+// SPDX-License-Identifier: LGPL-2.1-or-later
+/****************************************************************************
+ * *
+ * Copyright (c) 2025 Kindred Systems *
+ * *
+ * 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 *
+ * . *
+ * *
+ ***************************************************************************/
+
+#include "OndselAdapter.h"
+#include "SolverRegistry.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// For System::jointsMotionsDo and diagnostic iteration
+#include
+#include
+#include
+
+using namespace MbD;
+
+namespace KCSolve
+{
+
+// ── Static registration ────────────────────────────────────────────
+
+void OndselAdapter::register_solver()
+{
+ SolverRegistry::instance().register_solver(
+ "ondsel",
+ []() { return std::make_unique(); });
+}
+
+
+// ── IKCSolver identity ─────────────────────────────────────────────
+
+std::string OndselAdapter::name() const
+{
+ return "OndselSolver (Lagrangian)";
+}
+
+bool OndselAdapter::is_deterministic() const
+{
+ return true;
+}
+
+bool OndselAdapter::supports_bundle_fixed() const
+{
+ return false;
+}
+
+std::vector OndselAdapter::supported_joints() const
+{
+ return {
+ BaseJointKind::Coincident,
+ BaseJointKind::PointOnLine,
+ BaseJointKind::PointInPlane,
+ BaseJointKind::Concentric,
+ BaseJointKind::Tangent,
+ BaseJointKind::Planar,
+ BaseJointKind::LineInPlane,
+ BaseJointKind::Parallel,
+ BaseJointKind::Perpendicular,
+ BaseJointKind::Angle,
+ BaseJointKind::Fixed,
+ BaseJointKind::Revolute,
+ BaseJointKind::Cylindrical,
+ BaseJointKind::Slider,
+ BaseJointKind::Ball,
+ BaseJointKind::Screw,
+ BaseJointKind::Gear,
+ BaseJointKind::RackPinion,
+ BaseJointKind::DistancePointPoint,
+ BaseJointKind::DistanceCylSph,
+ };
+}
+
+
+// ── Quaternion → rotation matrix ───────────────────────────────────
+
+void OndselAdapter::quat_to_matrix(const std::array& q,
+ double (&mat)[3][3])
+{
+ double w = q[0], x = q[1], y = q[2], z = q[3];
+ double xx = x * x, yy = y * y, zz = z * z;
+ double xy = x * y, xz = x * z, yz = y * z;
+ double wx = w * x, wy = w * y, wz = w * z;
+
+ mat[0][0] = 1.0 - 2.0 * (yy + zz);
+ mat[0][1] = 2.0 * (xy - wz);
+ mat[0][2] = 2.0 * (xz + wy);
+ mat[1][0] = 2.0 * (xy + wz);
+ mat[1][1] = 1.0 - 2.0 * (xx + zz);
+ mat[1][2] = 2.0 * (yz - wx);
+ mat[2][0] = 2.0 * (xz - wy);
+ mat[2][1] = 2.0 * (yz + wx);
+ mat[2][2] = 1.0 - 2.0 * (xx + yy);
+}
+
+
+// ── Assembly building ──────────────────────────────────────────────
+
+std::shared_ptr