From 72e7e321337dbb18c5bad167b825f1d78cda5229 Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 20 Feb 2026 23:47:50 -0600 Subject: [PATCH] feat(solver): KCSolve solver addon with assembly integration (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Kindred constraint solver as a pluggable Assembly workbench backend, covering phases 3d through 5 of the solver roadmap. Phase 3d: SolveContext packing - Pack/unpack SolveContext into .kc archive on document save Solver addon (mods/solver): - Phase 1: Expression DAG, Newton-Raphson + BFGS, 3 basic constraints - Phase 2: Full constraint vocabulary — all 24 BaseJointKind types - Phase 3: Graph decomposition for cluster-by-cluster solving - Phase 4: Per-entity DOF diagnostics, overconstrained detection, half-space preference tracking, minimum-movement weighting - Phase 5: _build_system extraction, diagnose(), drag protocol, joint limits warning Assembly workbench integration: - Preference-driven solver selection (reads Mod/Assembly/Solver param) - Solver backend combo box in Assembly preferences UI - resetSolver() on AssemblyObject for live preference switching - Integration tests (TestKindredSolverIntegration.py) - In-client console test script (console_test_phase5.py) --- .gitmodules | 4 + mods/solver | 1 + src/Mod/Assembly/App/AssemblyObject.cpp | 22 +- src/Mod/Assembly/App/AssemblyObject.h | 5 + src/Mod/Assembly/App/AssemblyObject.pyi | 19 +- src/Mod/Assembly/App/AssemblyObjectPyImp.cpp | 201 +++++++++++++++++- .../TestKindredSolverIntegration.py | 180 ++++++++++++++++ src/Mod/Assembly/CMakeLists.txt | 1 + .../Gui/Resources/preferences/Assembly.ui | 14 ++ src/Mod/Assembly/Preferences.py | 23 +- src/Mod/Assembly/TestAssemblyWorkbench.py | 2 + src/Mod/Create/CMakeLists.txt | 15 ++ src/Mod/Create/kc_format.py | 18 ++ src/Mod/Create/silo_tree.py | 6 + 14 files changed, 503 insertions(+), 8 deletions(-) create mode 160000 mods/solver create mode 100644 src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py diff --git a/.gitmodules b/.gitmodules index d20f33113e..0bd5614cad 100644 --- a/.gitmodules +++ b/.gitmodules @@ -18,3 +18,7 @@ path = mods/silo url = https://git.kindred-systems.com/kindred/silo-mod.git branch = main +[submodule "mods/solver"] + path = mods/solver + url = https://git.kindred-systems.com/kindred/solver.git + branch = main diff --git a/mods/solver b/mods/solver new file mode 160000 index 0000000000..adaa0f9a69 --- /dev/null +++ b/mods/solver @@ -0,0 +1 @@ +Subproject commit adaa0f9a690a311db553158eed82d88f1f911913 diff --git a/src/Mod/Assembly/App/AssemblyObject.cpp b/src/Mod/Assembly/App/AssemblyObject.cpp index 89400b1f3c..26ff5ec4af 100644 --- a/src/Mod/Assembly/App/AssemblyObject.cpp +++ b/src/Mod/Assembly/App/AssemblyObject.cpp @@ -147,11 +147,31 @@ void AssemblyObject::onChanged(const App::Property* prop) KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver() { if (!solver_) { - solver_ = KCSolve::SolverRegistry::instance().get("ondsel"); + ParameterGrp::handle hGrp = App::GetApplication().GetParameterGroupByPath( + "User parameter:BaseApp/Preferences/Mod/Assembly"); + std::string solverName = hGrp->GetASCII("Solver", ""); + solver_ = KCSolve::SolverRegistry::instance().get(solverName); + // get("") returns the registry default (first registered solver) } return solver_.get(); } +KCSolve::SolveContext AssemblyObject::getSolveContext() +{ + partIdToObjs_.clear(); + objToPartId_.clear(); + + auto groundedObjs = getGroundedParts(); + if (groundedObjs.empty()) { + return {}; + } + + std::vector joints = getJoints(false); + removeUnconnectedJoints(joints, groundedObjs); + + return buildSolveContext(joints); +} + int AssemblyObject::solve(bool enableRedo, bool updateJCS) { ensureIdentityPlacements(); diff --git a/src/Mod/Assembly/App/AssemblyObject.h b/src/Mod/Assembly/App/AssemblyObject.h index cffe0d2483..6355040f5c 100644 --- a/src/Mod/Assembly/App/AssemblyObject.h +++ b/src/Mod/Assembly/App/AssemblyObject.h @@ -98,10 +98,15 @@ public: void postDrag(); void savePlacementsForUndo(); void undoSolve(); + void resetSolver() { solver_.reset(); } void clearUndo(); void exportAsASMT(std::string fileName); + /// Build the assembly constraint graph without solving. + /// Returns an empty SolveContext if no parts are grounded. + KCSolve::SolveContext getSolveContext(); + bool validateNewPlacements(); void setNewPlacements(); static void redrawJointPlacements(std::vector joints); diff --git a/src/Mod/Assembly/App/AssemblyObject.pyi b/src/Mod/Assembly/App/AssemblyObject.pyi index 95db04dc83..9874eff5c4 100644 --- a/src/Mod/Assembly/App/AssemblyObject.pyi +++ b/src/Mod/Assembly/App/AssemblyObject.pyi @@ -4,10 +4,9 @@ from __future__ import annotations from typing import Any, Final -from Base.Metadata import constmethod, export - -from App.Part import Part from App.DocumentObject import DocumentObject +from App.Part import Part +from Base.Metadata import constmethod, export @export(Include="Mod/Assembly/App/AssemblyObject.h", Namespace="Assembly") class AssemblyObject(Part): @@ -119,7 +118,9 @@ class AssemblyObject(Part): ... @constmethod - def isJointConnectingPartToGround(self, joint: DocumentObject, prop_name: str, /) -> Any: + def isJointConnectingPartToGround( + self, joint: DocumentObject, prop_name: str, / + ) -> Any: """ Check if a joint is connecting a part to the ground. @@ -153,6 +154,16 @@ class AssemblyObject(Part): """ ... + @constmethod + def getSolveContext(self) -> dict: + """Build the assembly constraint graph as a serializable dict. + + Returns a dict matching kcsolve.SolveContext.to_dict() format, + or an empty dict if the assembly has no grounded parts. + Does NOT trigger a solve. + """ + ... + @constmethod def getDownstreamParts( self, start_part: DocumentObject, joint_to_ignore: DocumentObject, / diff --git a/src/Mod/Assembly/App/AssemblyObjectPyImp.cpp b/src/Mod/Assembly/App/AssemblyObjectPyImp.cpp index fa5a28dd3b..18c03e8436 100644 --- a/src/Mod/Assembly/App/AssemblyObjectPyImp.cpp +++ b/src/Mod/Assembly/App/AssemblyObjectPyImp.cpp @@ -21,13 +21,161 @@ * * ***************************************************************************/ - -// inclusion of the generated files (generated out of AssemblyObject.xml) + + // inclusion of the generated files (generated out of AssemblyObject.xml) #include "AssemblyObjectPy.h" #include "AssemblyObjectPy.cpp" +#include + using namespace Assembly; +namespace +{ + +// ── Enum-to-string tables for dict serialization ─────────────────── +// String values must match kcsolve_py.cpp py::enum_ .value() names exactly. + +const char* baseJointKindStr(KCSolve::BaseJointKind k) +{ + switch (k) { + case KCSolve::BaseJointKind::Coincident: return "Coincident"; + case KCSolve::BaseJointKind::PointOnLine: return "PointOnLine"; + case KCSolve::BaseJointKind::PointInPlane: return "PointInPlane"; + case KCSolve::BaseJointKind::Concentric: return "Concentric"; + case KCSolve::BaseJointKind::Tangent: return "Tangent"; + case KCSolve::BaseJointKind::Planar: return "Planar"; + case KCSolve::BaseJointKind::LineInPlane: return "LineInPlane"; + case KCSolve::BaseJointKind::Parallel: return "Parallel"; + case KCSolve::BaseJointKind::Perpendicular: return "Perpendicular"; + case KCSolve::BaseJointKind::Angle: return "Angle"; + case KCSolve::BaseJointKind::Fixed: return "Fixed"; + case KCSolve::BaseJointKind::Revolute: return "Revolute"; + case KCSolve::BaseJointKind::Cylindrical: return "Cylindrical"; + case KCSolve::BaseJointKind::Slider: return "Slider"; + case KCSolve::BaseJointKind::Ball: return "Ball"; + case KCSolve::BaseJointKind::Screw: return "Screw"; + case KCSolve::BaseJointKind::Universal: return "Universal"; + case KCSolve::BaseJointKind::Gear: return "Gear"; + case KCSolve::BaseJointKind::RackPinion: return "RackPinion"; + case KCSolve::BaseJointKind::Cam: return "Cam"; + case KCSolve::BaseJointKind::Slot: return "Slot"; + case KCSolve::BaseJointKind::DistancePointPoint: return "DistancePointPoint"; + case KCSolve::BaseJointKind::DistanceCylSph: return "DistanceCylSph"; + case KCSolve::BaseJointKind::Custom: return "Custom"; + } + return "Custom"; +} + +const char* limitKindStr(KCSolve::Constraint::Limit::Kind k) +{ + switch (k) { + case KCSolve::Constraint::Limit::Kind::TranslationMin: return "TranslationMin"; + case KCSolve::Constraint::Limit::Kind::TranslationMax: return "TranslationMax"; + case KCSolve::Constraint::Limit::Kind::RotationMin: return "RotationMin"; + case KCSolve::Constraint::Limit::Kind::RotationMax: return "RotationMax"; + } + return "TranslationMin"; +} + +const char* motionKindStr(KCSolve::MotionDef::Kind k) +{ + switch (k) { + case KCSolve::MotionDef::Kind::Rotational: return "Rotational"; + case KCSolve::MotionDef::Kind::Translational: return "Translational"; + case KCSolve::MotionDef::Kind::General: return "General"; + } + return "Rotational"; +} + +// ── Python dict builders ─────────────────────────────────────────── +// Layout matches solve_context_to_dict() in kcsolve_py.cpp exactly. + +Py::Dict transformToDict(const KCSolve::Transform& t) +{ + Py::Dict d; + d.setItem("position", Py::TupleN( + Py::Float(t.position[0]), + Py::Float(t.position[1]), + Py::Float(t.position[2]))); + d.setItem("quaternion", Py::TupleN( + Py::Float(t.quaternion[0]), + Py::Float(t.quaternion[1]), + Py::Float(t.quaternion[2]), + Py::Float(t.quaternion[3]))); + return d; +} + +Py::Dict partToDict(const KCSolve::Part& p) +{ + Py::Dict d; + d.setItem("id", Py::String(p.id)); + d.setItem("placement", transformToDict(p.placement)); + d.setItem("mass", Py::Float(p.mass)); + d.setItem("grounded", Py::Boolean(p.grounded)); + return d; +} + +Py::Dict limitToDict(const KCSolve::Constraint::Limit& lim) +{ + Py::Dict d; + d.setItem("kind", Py::String(limitKindStr(lim.kind))); + d.setItem("value", Py::Float(lim.value)); + d.setItem("tolerance", Py::Float(lim.tolerance)); + return d; +} + +Py::Dict constraintToDict(const KCSolve::Constraint& c) +{ + Py::Dict d; + d.setItem("id", Py::String(c.id)); + d.setItem("part_i", Py::String(c.part_i)); + d.setItem("marker_i", transformToDict(c.marker_i)); + d.setItem("part_j", Py::String(c.part_j)); + d.setItem("marker_j", transformToDict(c.marker_j)); + d.setItem("type", Py::String(baseJointKindStr(c.type))); + + Py::List params; + for (double v : c.params) { + params.append(Py::Float(v)); + } + d.setItem("params", params); + + Py::List lims; + for (const auto& l : c.limits) { + lims.append(limitToDict(l)); + } + d.setItem("limits", lims); + d.setItem("activated", Py::Boolean(c.activated)); + return d; +} + +Py::Dict motionToDict(const KCSolve::MotionDef& m) +{ + Py::Dict d; + d.setItem("kind", Py::String(motionKindStr(m.kind))); + d.setItem("joint_id", Py::String(m.joint_id)); + d.setItem("marker_i", Py::String(m.marker_i)); + d.setItem("marker_j", Py::String(m.marker_j)); + d.setItem("rotation_expr", Py::String(m.rotation_expr)); + d.setItem("translation_expr", Py::String(m.translation_expr)); + return d; +} + +Py::Dict simToDict(const KCSolve::SimulationParams& s) +{ + Py::Dict d; + d.setItem("t_start", Py::Float(s.t_start)); + d.setItem("t_end", Py::Float(s.t_end)); + d.setItem("h_out", Py::Float(s.h_out)); + d.setItem("h_min", Py::Float(s.h_min)); + d.setItem("h_max", Py::Float(s.h_max)); + d.setItem("error_tol", Py::Float(s.error_tol)); + return d; +} + +} // anonymous namespace + // returns a string which represents the object e.g. when printed in python std::string AssemblyObjectPy::representation() const { @@ -243,3 +391,52 @@ PyObject* AssemblyObjectPy::getDownstreamParts(PyObject* args) const return Py::new_reference_to(ret); } + +PyObject* AssemblyObjectPy::getSolveContext(PyObject* args) const +{ + if (!PyArg_ParseTuple(args, "")) { + return nullptr; + } + PY_TRY + { + KCSolve::SolveContext ctx = getAssemblyObjectPtr()->getSolveContext(); + + // Empty context (no grounded parts) → return empty dict + if (ctx.parts.empty()) { + return Py::new_reference_to(Py::Dict()); + } + + Py::Dict d; + d.setItem("api_version", Py::Long(KCSolve::API_VERSION_MAJOR)); + + Py::List parts; + for (const auto& p : ctx.parts) { + parts.append(partToDict(p)); + } + d.setItem("parts", parts); + + Py::List constraints; + for (const auto& c : ctx.constraints) { + constraints.append(constraintToDict(c)); + } + d.setItem("constraints", constraints); + + Py::List motions; + for (const auto& m : ctx.motions) { + motions.append(motionToDict(m)); + } + d.setItem("motions", motions); + + if (ctx.simulation.has_value()) { + d.setItem("simulation", simToDict(*ctx.simulation)); + } + else { + d.setItem("simulation", Py::None()); + } + + d.setItem("bundle_fixed", Py::Boolean(ctx.bundle_fixed)); + + return Py::new_reference_to(d); + } + PY_CATCH; +} diff --git a/src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py b/src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py new file mode 100644 index 0000000000..bd69477183 --- /dev/null +++ b/src/Mod/Assembly/AssemblyTests/TestKindredSolverIntegration.py @@ -0,0 +1,180 @@ +# 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 * +# . * +# * +# ***************************************************************************/ + +""" +Integration tests for the Kindred solver backend. + +These tests mirror TestSolverIntegration but force the solver preference +to "kindred" so the full pipeline (AssemblyObject → IKCSolver → +KindredSolver) is exercised. +""" + +import unittest + +import FreeCAD as App +import JointObject + + +def _pref(): + return App.ParamGet("User parameter:BaseApp/Preferences/Mod/Assembly") + + +class TestKindredSolverIntegration(unittest.TestCase): + """Full-stack solver tests using the Kindred (Newton-Raphson) backend.""" + + def setUp(self): + # Force the kindred solver backend + self._prev_solver = _pref().GetString("Solver", "") + _pref().SetString("Solver", "kindred") + + doc_name = self.__class__.__name__ + if App.ActiveDocument: + if App.ActiveDocument.Name != doc_name: + App.newDocument(doc_name) + else: + App.newDocument(doc_name) + App.setActiveDocument(doc_name) + self.doc = App.ActiveDocument + + self.assembly = self.doc.addObject("Assembly::AssemblyObject", "Assembly") + # Reset the solver so it picks up the new preference + self.assembly.resetSolver() + self.jointgroup = self.assembly.newObject("Assembly::JointGroup", "Joints") + + def tearDown(self): + App.closeDocument(self.doc.Name) + _pref().SetString("Solver", self._prev_solver) + + # ── Helpers ───────────────────────────────────────────────────── + + def _make_box(self, x=0, y=0, z=0, size=10): + box = self.assembly.newObject("Part::Box", "Box") + box.Length = size + box.Width = size + box.Height = size + box.Placement = App.Placement(App.Vector(x, y, z), App.Rotation()) + return box + + def _ground(self, obj): + gnd = self.jointgroup.newObject("App::FeaturePython", "GroundedJoint") + JointObject.GroundedJoint(gnd, obj) + return gnd + + def _make_joint(self, joint_type, ref1, ref2): + joint = self.jointgroup.newObject("App::FeaturePython", "Joint") + JointObject.Joint(joint, joint_type) + refs = [ + [ref1[0], ref1[1]], + [ref2[0], ref2[1]], + ] + joint.Proxy.setJointConnectors(joint, refs) + return joint + + # ── Tests ─────────────────────────────────────────────────────── + + def test_solve_fixed_joint(self): + """Two boxes + grounded + fixed joint -> placements match.""" + box1 = self._make_box(10, 20, 30) + box2 = self._make_box(40, 50, 60) + self._ground(box2) + + self._make_joint( + 0, + [box2, ["Face6", "Vertex7"]], + [box1, ["Face6", "Vertex7"]], + ) + + self.assertTrue( + box1.Placement.isSame(box2.Placement, 1e-6), + "Fixed joint: box1 should match box2 placement", + ) + + def test_solve_revolute_joint(self): + """Two boxes + grounded + revolute joint -> solve succeeds.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(100, 0, 0) + self._ground(box1) + + self._make_joint( + 1, + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ) + + result = self.assembly.solve() + self.assertEqual(result, 0, "Revolute joint solve should succeed") + + def test_solve_returns_code_for_no_ground(self): + """Assembly with no grounded parts -> solve returns -6.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(50, 0, 0) + + joint = self.jointgroup.newObject("App::FeaturePython", "Joint") + JointObject.Joint(joint, 0) + refs = [ + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ] + joint.Proxy.setJointConnectors(joint, refs) + + result = self.assembly.solve() + self.assertEqual(result, -6, "No grounded parts should return -6") + + def test_solve_dof_reporting(self): + """Revolute joint -> DOF = 1.""" + box1 = self._make_box(0, 0, 0) + box2 = self._make_box(100, 0, 0) + self._ground(box1) + + self._make_joint( + 1, + [box1, ["Face6", "Vertex7"]], + [box2, ["Face6", "Vertex7"]], + ) + + self.assembly.solve() + dof = self.assembly.getLastDoF() + self.assertEqual(dof, 1, "Revolute joint should leave 1 DOF") + + def test_solve_stability(self): + """Solving twice produces identical placements.""" + box1 = self._make_box(10, 20, 30) + box2 = self._make_box(40, 50, 60) + self._ground(box2) + + self._make_joint( + 0, + [box2, ["Face6", "Vertex7"]], + [box1, ["Face6", "Vertex7"]], + ) + + self.assembly.solve() + plc_first = App.Placement(box1.Placement) + + self.assembly.solve() + plc_second = box1.Placement + + self.assertTrue( + plc_first.isSame(plc_second, 1e-6), + "Deterministic solver should produce identical results", + ) diff --git a/src/Mod/Assembly/CMakeLists.txt b/src/Mod/Assembly/CMakeLists.txt index b8ceb8a5f5..0e9b49d7d6 100644 --- a/src/Mod/Assembly/CMakeLists.txt +++ b/src/Mod/Assembly/CMakeLists.txt @@ -58,6 +58,7 @@ SET(AssemblyTests_SRCS AssemblyTests/TestCore.py AssemblyTests/TestCommandInsertLink.py AssemblyTests/TestSolverIntegration.py + AssemblyTests/TestKindredSolverIntegration.py AssemblyTests/TestKCSolvePy.py AssemblyTests/mocks/__init__.py AssemblyTests/mocks/MockGui.py diff --git a/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui b/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui index 10ebe705fe..513b4a589c 100644 --- a/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui +++ b/src/Mod/Assembly/Gui/Resources/preferences/Assembly.ui @@ -84,6 +84,20 @@ The files are named "runPreDrag.asmt" and "dragging.log" and are located in the + + + Solver backend + + + + + + + Select the constraint solver used for assembly solving + + + + Qt::Vertical diff --git a/src/Mod/Assembly/Preferences.py b/src/Mod/Assembly/Preferences.py index 5bb07ccea6..bfa5022dd6 100644 --- a/src/Mod/Assembly/Preferences.py +++ b/src/Mod/Assembly/Preferences.py @@ -40,13 +40,34 @@ class PreferencesPage: pref.SetBool("LeaveEditWithEscape", self.form.checkBoxEnableEscape.isChecked()) pref.SetBool("LogSolverDebug", self.form.checkBoxSolverDebug.isChecked()) pref.SetInt("GroundFirstPart", self.form.groundFirstPart.currentIndex()) + idx = self.form.solverBackend.currentIndex() + solver_name = self.form.solverBackend.itemData(idx) or "" + pref.SetString("Solver", solver_name) def loadSettings(self): pref = preferences() - self.form.checkBoxEnableEscape.setChecked(pref.GetBool("LeaveEditWithEscape", True)) + self.form.checkBoxEnableEscape.setChecked( + pref.GetBool("LeaveEditWithEscape", True) + ) self.form.checkBoxSolverDebug.setChecked(pref.GetBool("LogSolverDebug", False)) self.form.groundFirstPart.clear() self.form.groundFirstPart.addItem(translate("Assembly", "Ask")) self.form.groundFirstPart.addItem(translate("Assembly", "Always")) self.form.groundFirstPart.addItem(translate("Assembly", "Never")) self.form.groundFirstPart.setCurrentIndex(pref.GetInt("GroundFirstPart", 0)) + + self.form.solverBackend.clear() + self.form.solverBackend.addItem(translate("Assembly", "Default"), "") + try: + import kcsolve + + for name in kcsolve.available(): + solver = kcsolve.load(name) + self.form.solverBackend.addItem(solver.name(), name) + except ImportError: + pass + current = pref.GetString("Solver", "") + for i in range(self.form.solverBackend.count()): + if self.form.solverBackend.itemData(i) == current: + self.form.solverBackend.setCurrentIndex(i) + break diff --git a/src/Mod/Assembly/TestAssemblyWorkbench.py b/src/Mod/Assembly/TestAssemblyWorkbench.py index 87d83297fc..3ca60190ab 100644 --- a/src/Mod/Assembly/TestAssemblyWorkbench.py +++ b/src/Mod/Assembly/TestAssemblyWorkbench.py @@ -30,9 +30,11 @@ from AssemblyTests.TestKCSolvePy import ( TestKCSolveTypes, # noqa: F401 TestPySolver, # noqa: F401 ) +from AssemblyTests.TestKindredSolverIntegration import TestKindredSolverIntegration from AssemblyTests.TestSolverIntegration import TestSolverIntegration # Use the modules so that code checkers don't complain (flake8) True if TestCore else False True if TestCommandInsertLink else False True if TestSolverIntegration else False +True if TestKindredSolverIntegration else False diff --git a/src/Mod/Create/CMakeLists.txt b/src/Mod/Create/CMakeLists.txt index f21c9ba832..ec342f8702 100644 --- a/src/Mod/Create/CMakeLists.txt +++ b/src/Mod/Create/CMakeLists.txt @@ -84,3 +84,18 @@ install( DESTINATION mods/sdk ) + +# Install Kindred Solver addon +install( + DIRECTORY + ${CMAKE_SOURCE_DIR}/mods/solver/kindred_solver + DESTINATION + mods/solver +) +install( + FILES + ${CMAKE_SOURCE_DIR}/mods/solver/package.xml + ${CMAKE_SOURCE_DIR}/mods/solver/Init.py + DESTINATION + mods/solver +) diff --git a/src/Mod/Create/kc_format.py b/src/Mod/Create/kc_format.py index c5b2eaf124..21e7bcaa45 100644 --- a/src/Mod/Create/kc_format.py +++ b/src/Mod/Create/kc_format.py @@ -90,6 +90,24 @@ def _manifest_enrich_hook(doc, filename, entries): register_pre_reinject(_manifest_enrich_hook) +def _solver_context_hook(doc, filename, entries): + """Pack solver context into silo/solver/context.json for assemblies.""" + try: + for obj in doc.Objects: + if obj.TypeId == "Assembly::AssemblyObject": + ctx = obj.getSolveContext() + if ctx: # non-empty means we have grounded parts + entries["silo/solver/context.json"] = ( + json.dumps(ctx, indent=2) + "\n" + ).encode("utf-8") + break # one assembly per document + except Exception as exc: + FreeCAD.Console.PrintWarning(f"kc_format: solver context hook failed: {exc}\n") + + +register_pre_reinject(_solver_context_hook) + + KC_VERSION = "1.0" diff --git a/src/Mod/Create/silo_tree.py b/src/Mod/Create/silo_tree.py index 63f48d09f1..5ad522a55f 100644 --- a/src/Mod/Create/silo_tree.py +++ b/src/Mod/Create/silo_tree.py @@ -50,6 +50,12 @@ _KNOWN_ENTRIES = [ "Dependencies", ("links", lambda v: isinstance(v, list) and len(v) > 0), ), + ( + "silo/solver/context.json", + "SiloSolverContext", + "Solver Context", + ("parts", lambda v: isinstance(v, list) and len(v) > 0), + ), ]