Compare commits

..

1 Commits

Author SHA1 Message Date
forbes
c225ba7da2 feat(assembly): add diagnostic logging to solver and assembly
C++ (AssemblyObject):
- getOrCreateSolver: log which solver backend was loaded
- solve: log assembly name, grounded/joint counts, context size,
  result status with DOF and placement count, per-constraint
  diagnostics on failure
- preDrag/doDragStep/postDrag: log drag part count, per-step
  validation failures, and summary (total steps / rejected count)
- buildSolveContext: log grounded/free part counts, constraint count,
  limits count, and bundle_fixed flag

Python (kindred_solver submodule):
- solver.py: log solve entry/exit with timing, system build stats,
  decomposition decisions, Newton/BFGS fallback events, drag lifecycle
- decompose.py: log cluster stats and per-cluster convergence
- Init.py: FreeCAD log handler routing Python logging to Console
2026-02-21 10:08:10 -06:00
16 changed files with 47 additions and 358 deletions

View File

@@ -53,7 +53,7 @@ project(KindredCreate)
# Kindred Create version
set(KINDRED_CREATE_VERSION_MAJOR "0")
set(KINDRED_CREATE_VERSION_MINOR "1")
set(KINDRED_CREATE_VERSION_PATCH "5")
set(KINDRED_CREATE_VERSION_PATCH "0")
set(KINDRED_CREATE_VERSION "${KINDRED_CREATE_VERSION_MAJOR}.${KINDRED_CREATE_VERSION_MINOR}.${KINDRED_CREATE_VERSION_PATCH}")
# Underlying FreeCAD version

View File

@@ -2,7 +2,7 @@
**An engineering-focused parametric 3D CAD platform built on FreeCAD 1.0+**
Kindred Create 0.1.5 | FreeCAD 1.2.0 base
Kindred Create 0.1.0 | FreeCAD 1.2.0 base
[Website](https://www.kindred-systems.com/create) |
[Downloads](https://git.kindred-systems.com/kindred/create/releases) |

View File

@@ -13,7 +13,7 @@ Kindred Create uses **CMake** for build configuration, **pixi** (conda-based) fo
## CMake configuration
The root `CMakeLists.txt` defines:
- **Kindred Create version:** `0.1.5` (via `KINDRED_CREATE_VERSION`)
- **Kindred Create version:** `0.1.0` (via `KINDRED_CREATE_VERSION`)
- **FreeCAD base version:** `1.0.0` (via `FREECAD_VERSION`)
- CMake policy settings for compatibility
- ccache auto-detection
@@ -25,7 +25,7 @@ The root `CMakeLists.txt` defines:
The version flows from CMake to Python via `configure_file()`:
```
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.5")
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.0")
→ src/Mod/Create/version.py.in (template)
→ build/*/Mod/Create/version.py (generated)
→ update_checker.py (imports VERSION)

View File

@@ -157,7 +157,7 @@ Edit only the canonical file in `Stylesheets/` — the preference pack copy is g
Defined in the top-level `CMakeLists.txt` and injected as compiler definitions:
```cmake
set(KINDRED_CREATE_VERSION "0.1.5")
set(KINDRED_CREATE_VERSION "0.1.0")
set(FREECAD_VERSION "1.0.0")
add_definitions(-DKINDRED_CREATE_VERSION="${KINDRED_CREATE_VERSION}")

View File

@@ -77,7 +77,7 @@ Defined in the root `CMakeLists.txt`:
| Constant | Value | Description |
|----------|-------|-------------|
| `KINDRED_CREATE_VERSION` | `0.1.5` | Kindred Create version |
| `KINDRED_CREATE_VERSION` | `0.1.0` | Kindred Create version |
| `FREECAD_VERSION` | `1.0.0` | FreeCAD base version |
These are injected into `src/Mod/Create/version.py` at build time via `version.py.in`.

View File

@@ -30,7 +30,7 @@ fi
# Get version from git if not provided
if [ -z "$VERSION" ]; then
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.5")
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.0")
fi
# Convert version to Debian-compatible format

View File

@@ -155,7 +155,6 @@ requirements:
- lark
- lxml
- matplotlib-base
- networkx
- nine
- noqt5
- numpy>=1.26,<2

View File

@@ -1046,9 +1046,6 @@ void Application::slotNewDocument(const App::Document& Doc, bool isMainDoc)
);
pDoc->signalInEdit.connect(std::bind(&Gui::Application::slotInEdit, this, sp::_1));
pDoc->signalResetEdit.connect(std::bind(&Gui::Application::slotResetEdit, this, sp::_1));
pDoc->signalActivatedViewProvider.connect(
std::bind(&Gui::Application::slotActivatedViewProvider, this, sp::_1, sp::_2)
);
// NOLINTEND
signalNewDocument(*pDoc, isMainDoc);
@@ -1355,12 +1352,6 @@ void Application::slotResetEdit(const Gui::ViewProviderDocumentObject& vp)
this->signalResetEdit(vp);
}
void Application::slotActivatedViewProvider(
const Gui::ViewProviderDocumentObject* vp, const char* name)
{
this->signalActivatedViewProvider(vp, name);
}
void Application::onLastWindowClosed(Gui::Document* pcDoc)
{
try {

View File

@@ -153,9 +153,6 @@ public:
fastsignals::signal<void(const Gui::ViewProviderDocumentObject&)> signalInEdit;
/// signal on leaving edit mode
fastsignals::signal<void(const Gui::ViewProviderDocumentObject&)> signalResetEdit;
/// signal on activated view-provider (active-object change, e.g. "pdbody", "part")
fastsignals::signal<void(const Gui::ViewProviderDocumentObject*, const char*)>
signalActivatedViewProvider;
/// signal on changing user edit mode
fastsignals::signal<void(int)> signalUserEditModeChanged;
//@}
@@ -177,7 +174,6 @@ protected:
void slotActivatedObject(const ViewProvider&);
void slotInEdit(const Gui::ViewProviderDocumentObject&);
void slotResetEdit(const Gui::ViewProviderDocumentObject&);
void slotActivatedViewProvider(const Gui::ViewProviderDocumentObject*, const char*);
public:
/// message when a GuiDocument is about to vanish

View File

@@ -121,9 +121,6 @@ EditingContextResolver::EditingContextResolver()
app.signalActiveDocument.connect([this](const Document& doc) { onActiveDocument(doc); });
app.signalActivateView.connect([this](const MDIView* view) { onActivateView(view); });
app.signalActivateWorkbench.connect([this](const char*) { refresh(); });
app.signalActivatedViewProvider.connect(
[this](const ViewProviderDocumentObject*, const char*) { refresh(); }
);
}
EditingContextResolver::~EditingContextResolver()
@@ -175,23 +172,6 @@ static App::DocumentObject* getActivePartObject()
return view->getActiveObject<App::DocumentObject*>("part");
}
// ---------------------------------------------------------------------------
// Helper: get the active "pdbody" object from the active view
// ---------------------------------------------------------------------------
static App::DocumentObject* getActivePdBodyObject()
{
auto* guiDoc = Application::Instance->activeDocument();
if (!guiDoc) {
return nullptr;
}
auto* view = guiDoc->getActiveView();
if (!view) {
return nullptr;
}
return view->getActiveObject<App::DocumentObject*>("pdbody");
}
// ---------------------------------------------------------------------------
// Helper: get the label of the active "part" object
// ---------------------------------------------------------------------------
@@ -233,34 +213,6 @@ static QString getInEditLabel()
void EditingContextResolver::registerBuiltinContexts()
{
// --- PartDesign body active inside an assembly (supersedes assembly.edit) ---
registerContext({
/*.id =*/QStringLiteral("partdesign.in_assembly"),
/*.labelTemplate =*/QStringLiteral("Body: {name}"),
/*.color =*/QLatin1String(CatppuccinMocha::Mauve),
/*.toolbars =*/
{QStringLiteral("Part Design Helper Features"),
QStringLiteral("Part Design Modeling Features"),
QStringLiteral("Part Design Dress-Up Features"),
QStringLiteral("Part Design Transformation Features"),
QStringLiteral("Sketcher")},
/*.priority =*/95,
/*.match =*/
[]() {
auto* body = getActivePdBodyObject();
if (!body || !objectIsDerivedFrom(body, "PartDesign::Body")) {
return false;
}
// Only match when we're inside an assembly edit session
auto* doc = Application::Instance->activeDocument();
if (!doc) {
return false;
}
auto* vp = doc->getInEdit();
return vp && vpObjectIsDerivedFrom(vp, "Assembly::AssemblyObject");
},
});
// --- Sketcher edit (highest priority — VP in edit) ---
registerContext({
/*.id =*/QStringLiteral("sketcher.edit"),
@@ -320,10 +272,7 @@ void EditingContextResolver::registerBuiltinContexts()
/*.priority =*/40,
/*.match =*/
[]() {
auto* obj = getActivePdBodyObject();
if (!obj) {
obj = getActivePartObject();
}
auto* obj = getActivePartObject();
if (!obj || !objectIsDerivedFrom(obj, "PartDesign::Body")) {
return false;
}
@@ -352,10 +301,7 @@ void EditingContextResolver::registerBuiltinContexts()
/*.priority =*/30,
/*.match =*/
[]() {
auto* obj = getActivePdBodyObject();
if (!obj) {
obj = getActivePartObject();
}
auto* obj = getActivePartObject();
return obj && objectIsDerivedFrom(obj, "PartDesign::Body");
},
});
@@ -542,13 +488,6 @@ EditingContext EditingContextResolver::resolve() const
if (label.contains(QStringLiteral("{name}"))) {
// For edit-mode contexts, use the in-edit object name
QString name = getInEditLabel();
if (name.isEmpty()) {
// Try pdbody first for PartDesign contexts
auto* bodyObj = getActivePdBodyObject();
if (bodyObj) {
name = QString::fromUtf8(bodyObj->Label.getValue());
}
}
if (name.isEmpty()) {
name = getActivePartLabel();
}
@@ -609,25 +548,6 @@ QStringList EditingContextResolver::buildBreadcrumb(const EditingContext& ctx) c
return crumbs;
}
// Assembly > Body breadcrumb for in-assembly part editing
if (ctx.id == QStringLiteral("partdesign.in_assembly")) {
auto* guiDoc = Application::Instance->activeDocument();
if (guiDoc) {
auto* vp = guiDoc->getInEdit();
if (vp) {
auto* vpd = dynamic_cast<ViewProviderDocumentObject*>(vp);
if (vpd && vpd->getObject()) {
crumbs << QString::fromUtf8(vpd->getObject()->Label.getValue());
}
}
}
auto* body = getActivePdBodyObject();
if (body) {
crumbs << QString::fromUtf8(body->Label.getValue());
}
return crumbs;
}
// Always start with the active part/body/assembly label
QString partLabel = getActivePartLabel();
if (!partLabel.isEmpty()) {
@@ -662,14 +582,6 @@ QStringList EditingContextResolver::buildBreadcrumbColors(const EditingContext&
{
QStringList colors;
if (ctx.id == QStringLiteral("partdesign.in_assembly")) {
for (int i = 0; i < ctx.breadcrumb.size(); ++i) {
colors << (i == 0 ? QLatin1String(CatppuccinMocha::Blue)
: QLatin1String(CatppuccinMocha::Mauve));
}
return colors;
}
if (ctx.breadcrumb.size() <= 1) {
colors << ctx.color;
return colors;

View File

@@ -298,14 +298,8 @@ void AssemblyObject::updateSolveStatus()
//+1 because there's a grounded joint to origin
lastDoF = (1 + numberOfComponents()) * 6;
// Guard against re-entrancy: solve() calls updateSolveStatus(), so if
// placements are legitimately empty (e.g. zero constraints / all parts
// grounded) the recursive solve() would never terminate.
static bool updating = false;
if (!updating && (!solver_ || lastResult_.placements.empty())) {
updating = true;
if (!solver_ || lastResult_.placements.empty()) {
solve();
updating = false;
}
if (!solver_) {
@@ -1115,19 +1109,10 @@ KCSolve::SolveContext AssemblyObject::buildSolveContext(
break;
default:
FC_WARN("Assembly : Distance joint '" << joint->getFullName()
<< "' — unhandled DistanceType "
<< distanceTypeName(distType)
<< ", falling back to Planar");
kind = KCSolve::BaseJointKind::Planar;
params.push_back(distance);
break;
}
FC_LOG("Assembly : Distance joint '" << joint->getFullName()
<< "' — DistanceType=" << distanceTypeName(distType)
<< ", kind=" << static_cast<int>(kind)
<< ", distance=" << distance);
break;
}
default:

View File

@@ -54,56 +54,10 @@
namespace PartApp = Part;
FC_LOG_LEVEL_INIT("Assembly", true, true, true)
// ======================================= Utils ======================================
namespace Assembly
{
const char* distanceTypeName(DistanceType dt)
{
switch (dt) {
case DistanceType::PointPoint: return "PointPoint";
case DistanceType::LineLine: return "LineLine";
case DistanceType::LineCircle: return "LineCircle";
case DistanceType::CircleCircle: return "CircleCircle";
case DistanceType::PlanePlane: return "PlanePlane";
case DistanceType::PlaneCylinder: return "PlaneCylinder";
case DistanceType::PlaneSphere: return "PlaneSphere";
case DistanceType::PlaneCone: return "PlaneCone";
case DistanceType::PlaneTorus: return "PlaneTorus";
case DistanceType::CylinderCylinder: return "CylinderCylinder";
case DistanceType::CylinderSphere: return "CylinderSphere";
case DistanceType::CylinderCone: return "CylinderCone";
case DistanceType::CylinderTorus: return "CylinderTorus";
case DistanceType::ConeCone: return "ConeCone";
case DistanceType::ConeTorus: return "ConeTorus";
case DistanceType::ConeSphere: return "ConeSphere";
case DistanceType::TorusTorus: return "TorusTorus";
case DistanceType::TorusSphere: return "TorusSphere";
case DistanceType::SphereSphere: return "SphereSphere";
case DistanceType::PointPlane: return "PointPlane";
case DistanceType::PointCylinder: return "PointCylinder";
case DistanceType::PointSphere: return "PointSphere";
case DistanceType::PointCone: return "PointCone";
case DistanceType::PointTorus: return "PointTorus";
case DistanceType::LinePlane: return "LinePlane";
case DistanceType::LineCylinder: return "LineCylinder";
case DistanceType::LineSphere: return "LineSphere";
case DistanceType::LineCone: return "LineCone";
case DistanceType::LineTorus: return "LineTorus";
case DistanceType::CurvePlane: return "CurvePlane";
case DistanceType::CurveCylinder: return "CurveCylinder";
case DistanceType::CurveSphere: return "CurveSphere";
case DistanceType::CurveCone: return "CurveCone";
case DistanceType::CurveTorus: return "CurveTorus";
case DistanceType::PointLine: return "PointLine";
case DistanceType::PointCurve: return "PointCurve";
case DistanceType::Other: return "Other";
}
return "Unknown";
}
void swapJCS(const App::DocumentObject* joint)
{
if (!joint) {
@@ -210,56 +164,6 @@ DistanceType getDistanceType(App::DocumentObject* joint)
auto* obj1 = getLinkedObjFromRef(joint, "Reference1");
auto* obj2 = getLinkedObjFromRef(joint, "Reference2");
// Datum planes (App::Plane) have empty element types because their
// sub-name ends with "." and yields no Face/Edge/Vertex element.
// Detect them here and classify before the main geometry chain,
// which cannot handle the empty element type.
const bool datum1 = type1.empty() && obj1 && obj1->isDerivedFrom<App::Plane>();
const bool datum2 = type2.empty() && obj2 && obj2->isDerivedFrom<App::Plane>();
if (datum1 || datum2) {
if (datum1 && datum2) {
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datum+datum → PlanePlane");
return DistanceType::PlanePlane;
}
// One side is a datum plane, the other has a real element type.
// For PointPlane/LinePlane, the solver's PointInPlaneConstraint
// reads the plane normal from marker_j (Reference2). Unlike
// real Face+Vertex joints (where both Placements carry the
// face normal from findPlacement), datum planes only carry
// their normal through computeMarkerTransform. So the datum
// must end up on Reference2 for the normal to reach marker_j.
//
// For PlanePlane the convention matches the existing Face+Face
// path (plane on Reference1).
const auto& otherType = datum1 ? type2 : type1;
if (otherType == "Vertex" || otherType == "Edge") {
// Datum must be on Reference2 (j side).
if (datum1) {
swapJCS(joint); // move datum from Ref1 → Ref2
}
DistanceType result = (otherType == "Vertex")
? DistanceType::PointPlane : DistanceType::LinePlane;
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datum+" << otherType << ""
<< distanceTypeName(result)
<< (datum1 ? " (swapped)" : ""));
return result;
}
// Face + datum or unknown + datum → PlanePlane.
// No swap needed: PlanarConstraint is symmetric (uses both
// z_i and z_j), and preserving the original Reference order
// keeps the initial Placement values consistent so the solver
// stays in the correct orientation branch.
FC_LOG("Assembly : getDistanceType('" << joint->getFullName()
<< "') — datum+" << otherType << " → PlanePlane");
return DistanceType::PlanePlane;
}
if (type1 == "Vertex" && type2 == "Vertex") {
return DistanceType::PointPoint;
}

View File

@@ -148,7 +148,6 @@ AssemblyExport double getFaceRadius(const App::DocumentObject* obj, const std::s
AssemblyExport double getEdgeRadius(const App::DocumentObject* obj, const std::string& elName);
AssemblyExport DistanceType getDistanceType(App::DocumentObject* joint);
AssemblyExport const char* distanceTypeName(DistanceType dt);
AssemblyExport JointGroup* getJointGroup(const App::Part* part);
AssemblyExport std::vector<App::DocumentObject*> getAssemblyComponents(const AssemblyObject* assembly);

View File

@@ -191,90 +191,6 @@ class TestAssemblyOriginPlanes(unittest.TestCase):
result = self.assembly.solve()
self.assertEqual(result, 0, "Solve should succeed with origin plane joint")
# ── Distance joint to datum plane tests ────────────────────────
def test_distance_vertex_to_datum_plane_solves(self):
"""Distance(0) joint: vertex → datum plane solves and pins position."""
origin = self._get_origin()
xy = origin.getXY() # Top (Z normal)
xz = origin.getXZ() # Front (Y normal)
yz = origin.getYZ() # Right (X normal)
box = self._make_box(50, 50, 50)
# 3 Distance joints, each vertex→datum, distance=0.
# This should pin the box's Vertex1 (corner at local 0,0,0) to the
# origin, giving 3 PointInPlane constraints (1 residual each = 3 total).
for plane in [xy, xz, yz]:
joint = self._make_joint(
5, # Distance
[box, ["Vertex1", "Vertex1"]],
[origin, [plane.Name + ".", plane.Name + "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertEqual(
result, 0, "Solve should succeed for vertex→datum Distance joints"
)
# The box's Vertex1 (at local 0,0,0) should be at the origin.
v = box.Placement.Base
self.assertAlmostEqual(v.x, 0.0, places=2, msg="X should be pinned to 0")
self.assertAlmostEqual(v.y, 0.0, places=2, msg="Y should be pinned to 0")
self.assertAlmostEqual(v.z, 0.0, places=2, msg="Z should be pinned to 0")
def test_distance_vertex_to_datum_plane_preserves_orientation(self):
"""Distance(0) vertex→datum should not constrain orientation."""
origin = self._get_origin()
xy = origin.getXY()
xz = origin.getXZ()
yz = origin.getYZ()
# Start box with a known rotation (45° about Z).
rot = App.Rotation(App.Vector(0, 0, 1), 45)
box = self._make_box(50, 50, 50)
box.Placement = App.Placement(App.Vector(50, 50, 50), rot)
for plane in [xy, xz, yz]:
joint = self._make_joint(
5,
[box, ["Vertex1", "Vertex1"]],
[origin, [plane.Name + ".", plane.Name + "."]],
)
joint.Distance = 0.0
self.assembly.solve()
# 3 PointInPlane constraints pin position (3 DOF) but leave
# orientation free (3 DOF). The solver should keep the original
# orientation since it's the lowest-energy solution from the
# initial placement.
dof = self.assembly.getLastDoF()
self.assertEqual(
dof, 3, "3 PointInPlane constraints should leave 3 DOF (orientation)"
)
def test_distance_face_to_datum_plane_solves(self):
"""Distance(0) joint: face → datum plane solves (PlanePlane/Planar)."""
origin = self._get_origin()
xy = origin.getXY()
box = self._make_box(0, 0, 50)
# Face1 is the -Z face of a Part::Box.
joint = self._make_joint(
5,
[box, ["Face1", "Vertex1"]],
[origin, [xy.Name + ".", xy.Name + "."]],
)
joint.Distance = 0.0
result = self.assembly.solve()
self.assertEqual(
result, 0, "Solve should succeed for face→datum Distance joint"
)
# ── Round-trip test ──────────────────────────────────────────────
def test_save_load_preserves_labels(self):

View File

@@ -162,28 +162,34 @@ class _KcFormatObserver:
f"kc_format: pre_reinject hook failed: {exc}\n"
)
try:
# Ensure silo/manifest.json exists in entries and update modified_at.
# All manifest mutations happen here so only one copy is written.
if "silo/manifest.json" in entries:
try:
manifest = json.loads(entries["silo/manifest.json"])
except (json.JSONDecodeError, ValueError):
manifest = _default_manifest()
else:
manifest = _default_manifest()
manifest["modified_at"] = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
entries["silo/manifest.json"] = (
json.dumps(manifest, indent=2) + "\n"
).encode("utf-8")
with zipfile.ZipFile(filename, "a") as zf:
existing = set(zf.namelist())
for name, data in entries.items():
if name not in existing:
zf.writestr(name, data)
existing.add(name)
# Re-inject cached silo/ entries
if entries:
for name, data in entries.items():
if name not in existing:
zf.writestr(name, data)
existing.add(name)
# Ensure silo/manifest.json exists
if "silo/manifest.json" not in existing:
manifest = _default_manifest()
zf.writestr(
"silo/manifest.json",
json.dumps(manifest, indent=2) + "\n",
)
else:
# Update modified_at timestamp
raw = zf.read("silo/manifest.json")
manifest = json.loads(raw)
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if manifest.get("modified_at") != now:
manifest["modified_at"] = now
# ZipFile append mode can't overwrite; write new entry
# (last duplicate wins in most ZIP readers)
zf.writestr(
"silo/manifest.json",
json.dumps(manifest, indent=2) + "\n",
)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kc_format: failed to update .kc silo/ entries: {e}\n"
@@ -203,36 +209,17 @@ def update_manifest_fields(filename, updates):
return
if not os.path.isfile(filename):
return
import shutil
import tempfile
try:
fd, tmp = tempfile.mkstemp(suffix=".kc", dir=os.path.dirname(filename))
os.close(fd)
try:
with (
zipfile.ZipFile(filename, "r") as zin,
zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zout,
):
found = False
for item in zin.infolist():
if item.filename == "silo/manifest.json":
if found:
continue # skip duplicate entries
found = True
raw = zin.read(item.filename)
manifest = json.loads(raw)
manifest.update(updates)
zout.writestr(
item.filename,
json.dumps(manifest, indent=2) + "\n",
)
else:
zout.writestr(item, zin.read(item.filename))
shutil.move(tmp, filename)
except BaseException:
os.unlink(tmp)
raise
with zipfile.ZipFile(filename, "a") as zf:
if "silo/manifest.json" not in zf.namelist():
return
raw = zf.read("silo/manifest.json")
manifest = json.loads(raw)
manifest.update(updates)
zf.writestr(
"silo/manifest.json",
json.dumps(manifest, indent=2) + "\n",
)
except Exception as e:
FreeCAD.Console.PrintWarning(f"kc_format: failed to update manifest: {e}\n")