From 4cf54caf7b27b15912fe42305b0b3cc8d79e74a9 Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 20 Feb 2026 17:12:25 -0600 Subject: [PATCH] feat(solver): pack SolveContext into .kc archives on save (#289 phase 3d) Expose AssemblyObject::getSolveContext() to Python and hook into the .kc save flow so that silo/solver/context.json is packed into every assembly archive. This lets server-side solver runners operate on pre-extracted constraint graphs without a full FreeCAD installation. Changes: - Add public getSolveContext() to AssemblyObject (C++ and Python) - Build Python dict via CPython C API matching kcsolve.SolveContext.to_dict() - Register _solver_context_hook in kc_format.py pre-reinject hooks - Add silo/solver/context.json to silo_tree.py _KNOWN_ENTRIES --- src/Mod/Assembly/App/AssemblyObject.cpp | 16 ++ src/Mod/Assembly/App/AssemblyObject.h | 4 + src/Mod/Assembly/App/AssemblyObject.pyi | 19 +- src/Mod/Assembly/App/AssemblyObjectPyImp.cpp | 201 ++++++++++++++++++- src/Mod/Create/kc_format.py | 18 ++ src/Mod/Create/silo_tree.py | 6 + 6 files changed, 258 insertions(+), 6 deletions(-) diff --git a/src/Mod/Assembly/App/AssemblyObject.cpp b/src/Mod/Assembly/App/AssemblyObject.cpp index 89400b1f3c..bb55bc6f00 100644 --- a/src/Mod/Assembly/App/AssemblyObject.cpp +++ b/src/Mod/Assembly/App/AssemblyObject.cpp @@ -152,6 +152,22 @@ KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver() 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..c01e69a486 100644 --- a/src/Mod/Assembly/App/AssemblyObject.h +++ b/src/Mod/Assembly/App/AssemblyObject.h @@ -102,6 +102,10 @@ public: 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/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), + ), ]