Compare commits

...

20 Commits

Author SHA1 Message Date
forbes
cf0cd3db7e fix(assembly): classify datum plane references in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-22 15:52:22 -06:00
cf2fc82eac Merge pull request 'fix(assembly): classify datum plane references in Distance joints' (#315) from fix/distance-datum-plane-classification into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #315
2026-02-22 18:25:32 +00:00
forbes
e5b07449d7 fix(assembly): classify datum plane references in Distance joints
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-22 12:24:44 -06:00
58d98c6d92 Merge pull request 'fix(assembly): classify datum plane references in Distance joints' (#314) from fix/distance-datum-plane-classification into main
All checks were successful
Build and Test / build (push) Successful in 41m35s
Sync Silo Server Docs / sync (push) Successful in 34s
Reviewed-on: #314
2026-02-22 04:04:41 +00:00
forbes
a10b9d9a9f fix(assembly): classify datum plane references in Distance joints
All checks were successful
Build and Test / build (pull_request) Successful in 41m13s
When a Distance joint references a datum plane (XY_Plane, XZ_Plane,
YZ_Plane), getDistanceType() failed to recognize it because datum
plane sub-names yield an empty element type. This caused the fallback
to DistanceType::Other → BaseJointKind::Planar, which adds spurious
parallel-normal residuals that overconstrain the system.

For example, three vertex-to-datum-plane Distance joints produced
10 residuals (3×Planar) with 6 mutually contradictory orientation
constraints, causing the solver to find garbage least-squares
solutions.

Add early detection of App::Plane datum objects before the main
geometry classification chain. Datum planes are now correctly mapped:
- Vertex + DatumPlane → PointPlane → PointInPlane (1 residual)
- Edge + DatumPlane → LinePlane → LineInPlane
- Face/DatumPlane + DatumPlane → PlanePlane → Planar
2026-02-21 22:04:18 -06:00
forbes
d0e6d91642 chore: update solver submodule (drag step caching)
Some checks failed
Build and Test / build (push) Has been cancelled
2026-02-21 12:23:42 -06:00
forbes
05428f8a1c chore: bump version to 0.1.5
Some checks failed
Deploy Docs / build-and-deploy (push) Successful in 40s
Build and Test / build (push) Has been cancelled
2026-02-21 12:05:09 -06:00
forbes
14f314e137 chore: update solver submodule (distance=0 fix)
Some checks failed
Build and Test / build (push) Has been cancelled
2026-02-21 11:46:52 -06:00
forbes
30c35af3be chore: update solver submodule (compiled Jacobian evaluation)
Some checks are pending
Build and Test / build (push) Has started running
2026-02-21 11:42:48 -06:00
441cf9e826 Merge pull request 'feat(assembly): add diagnostic logging to solver and assembly' (#313) from feat/solver-diagnostic-logging into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #313
2026-02-21 16:11:34 +00:00
forbes
c682c5d153 feat(assembly): add diagnostic logging to solver and assembly
Some checks failed
Build and Test / build (pull_request) Has been cancelled
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:51 -06:00
f65a4a5e2b Merge pull request 'fix(assembly): update flip-detection baseline during drag steps' (#312) from fix/assembly-drag-flip-detection into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #312
2026-02-21 15:59:55 +00:00
a445275fd2 Merge pull request 'fix(kc_format): eliminate duplicate silo/manifest.json in .kc files' (#311) from fix/kc-duplicate-manifest into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #311
2026-02-21 15:50:58 +00:00
forbes
88efa2a6ae fix(kc_format): eliminate duplicate silo/manifest.json entries in .kc files
Some checks failed
Build and Test / build (pull_request) Has been cancelled
Two code paths were appending silo/manifest.json to the ZIP without
removing the previous entry, causing Python's zipfile module to warn
about duplicate names:

1. slotFinishSaveDocument() re-injected the cached manifest from
   entries, then the modified_at update branch wrote a second copy.

2. update_manifest_fields() opened the ZIP in append mode and wrote
   an updated manifest without removing the old one.

Fix slotFinishSaveDocument() by preparing the final manifest (with
updated modified_at) in the entries dict before writing, so only one
copy is written to the ZIP.

Fix update_manifest_fields() by rewriting the ZIP atomically via a
temp file, deduplicating any pre-existing duplicate entries in the
process.
2026-02-21 09:49:36 -06:00
62f077a267 Merge pull request 'fix(assembly): prevent segfault when all joints are removed' (#310) from fix/assembly-empty-joints-segfault into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #310
2026-02-21 15:47:59 +00:00
forbes
b6b0ebb4dc fix(assembly): prevent segfault when all joints are removed
Some checks failed
Build and Test / build (pull_request) Has been cancelled
updateSolveStatus() calls solve() when lastResult_.placements is empty,
but solve() calls updateSolveStatus() at the end. When an assembly has
zero constraints (all joints removed), the solver returns zero
placements, causing infinite recursion until stack overflow (segfault).

Add a static re-entrancy guard so the recursive solve() call is skipped
if updateSolveStatus() is already on the call stack.
2026-02-21 09:47:15 -06:00
a6d0427639 Merge pull request 'fix(gui): resolve PartDesign toolbars and breadcrumb when editing body inside assembly' (#309) from fix/partdesign-context-in-assembly into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #309
2026-02-21 15:44:49 +00:00
forbes
5883ac8a0d fix(gui): resolve PartDesign toolbars and breadcrumb when editing body inside assembly
Some checks failed
Build and Test / build (pull_request) Has been cancelled
When double-clicking a PartDesign Body inside an Assembly, the editing
context resolver failed to show PartDesign toolbars or a proper
breadcrumb. Three root causes:

1. Priority preemption: assembly.edit (priority 90) always matched
   because the Assembly VP remained in edit mode, blocking all
   PartDesign contexts (priorities 30-40).

2. Wrong active-object key: PartDesign matchers only queried the
   "part" active object, but ViewProviderBody::toggleActiveBody()
   sets "part" to the containing App::Part (which is the Assembly
   itself). The Body is set under "pdbody", which was never queried.

3. Missing refresh trigger: ActiveObjectList::setObject() fires
   Document::signalActivatedViewProvider, but EditingContextResolver
   had no connection to it, so setActiveObject("pdbody", body) never
   triggered a context re-resolve.

Changes:
- Forward signalActivatedViewProvider from Gui::Document to
  Gui::Application (same pattern as signalInEdit/signalResetEdit)
- Connect EditingContextResolver to the new application-level signal
- Add getActivePdBodyObject() helper querying the "pdbody" key
- Add partdesign.in_assembly context (priority 95) that matches when
  a Body is active pdbody while an Assembly is in edit
- Update partdesign.body and partdesign.feature matchers to check
  pdbody before falling back to the part key
- Add Assembly > Body breadcrumb with Blue > Mauve coloring
- Update label resolution to prefer pdbody name
2026-02-21 09:43:51 -06:00
f9b13710f3 Merge pull request 'fix(solver): add networkx to runtime dependencies' (#308) from fix/solver-networkx-dep into main
Some checks failed
Build and Test / build (push) Has been cancelled
Reviewed-on: #308
2026-02-21 15:29:30 +00:00
forbes
39e78ee0a2 fix(solver): add networkx to runtime dependencies
All checks were successful
Build and Test / build (pull_request) Successful in 30m3s
The kindred-solver addon imports networkx in its decompose module.
Without it, KindredSolver fails to import and solver registration
silently fails, leaving only the ondsel backend available.
2026-02-21 09:24:30 -06:00
17 changed files with 421 additions and 48 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 "0")
set(KINDRED_CREATE_VERSION_PATCH "5")
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.0 | FreeCAD 1.2.0 base
Kindred Create 0.1.5 | 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.0` (via `KINDRED_CREATE_VERSION`)
- **Kindred Create version:** `0.1.5` (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.0")
CMakeLists.txt (KINDRED_CREATE_VERSION = "0.1.5")
→ 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.0")
set(KINDRED_CREATE_VERSION "0.1.5")
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.0` | Kindred Create version |
| `KINDRED_CREATE_VERSION` | `0.1.5` | 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.0")
VERSION=$(cd "$PROJECT_ROOT" && git describe --tags --always 2>/dev/null || echo "0.1.5")
fi
# Convert version to Debian-compatible format

View File

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

View File

@@ -1046,6 +1046,9 @@ 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);
@@ -1352,6 +1355,12 @@ 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,6 +153,9 @@ 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;
//@}
@@ -174,6 +177,7 @@ 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,6 +121,9 @@ 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()
@@ -172,6 +175,23 @@ 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
// ---------------------------------------------------------------------------
@@ -213,6 +233,34 @@ 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"),
@@ -272,7 +320,10 @@ void EditingContextResolver::registerBuiltinContexts()
/*.priority =*/40,
/*.match =*/
[]() {
auto* obj = getActivePartObject();
auto* obj = getActivePdBodyObject();
if (!obj) {
obj = getActivePartObject();
}
if (!obj || !objectIsDerivedFrom(obj, "PartDesign::Body")) {
return false;
}
@@ -301,7 +352,10 @@ void EditingContextResolver::registerBuiltinContexts()
/*.priority =*/30,
/*.match =*/
[]() {
auto* obj = getActivePartObject();
auto* obj = getActivePdBodyObject();
if (!obj) {
obj = getActivePartObject();
}
return obj && objectIsDerivedFrom(obj, "PartDesign::Body");
},
});
@@ -488,6 +542,13 @@ 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();
}
@@ -548,6 +609,25 @@ 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()) {
@@ -582,6 +662,14 @@ 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

@@ -176,6 +176,10 @@ KCSolve::IKCSolver* AssemblyObject::getOrCreateSolver()
std::string solverName = hGrp->GetASCII("Solver", "");
solver_ = KCSolve::SolverRegistry::instance().get(solverName);
// get("") returns the registry default (first registered solver)
if (solver_) {
FC_LOG("Assembly : loaded solver '" << solver_->name()
<< "' (requested='" << solverName << "')");
}
}
return solver_.get();
}
@@ -212,14 +216,22 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) {
FC_LOG("Assembly : solve skipped — no grounded parts");
return -6;
}
std::vector<App::DocumentObject*> joints = getJoints(updateJCS);
removeUnconnectedJoints(joints, groundedObjs);
FC_LOG("Assembly : solve on '" << getFullLabel()
<< "' — " << groundedObjs.size() << " grounded, "
<< joints.size() << " joints");
KCSolve::SolveContext ctx = buildSolveContext(joints);
FC_LOG("Assembly : solve context — " << ctx.parts.size() << " parts, "
<< ctx.constraints.size() << " constraints");
// Always save placements to enable orientation flip detection
savePlacementsForUndo();
@@ -241,6 +253,13 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
}
if (lastResult_.status == KCSolve::SolveStatus::Failed) {
FC_LOG("Assembly : solve failed — status="
<< static_cast<int>(lastResult_.status)
<< ", " << lastResult_.diagnostics.size() << " diagnostics");
for (const auto& d : lastResult_.diagnostics) {
Base::Console().warning("Assembly : diagnostic [%s]: %s\n",
d.constraint_id.c_str(), d.detail.c_str());
}
updateSolveStatus();
return -1;
}
@@ -248,6 +267,7 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
// Validate that the solve didn't cause any parts to flip orientation
if (!validateNewPlacements()) {
// Restore previous placements - the solve found an invalid configuration
FC_LOG("Assembly : solve rejected — placement validation failed, undoing");
undoSolve();
lastSolverStatus = -2;
updateSolveStatus();
@@ -265,6 +285,9 @@ int AssemblyObject::solve(bool enableRedo, bool updateJCS)
updateSolveStatus();
FC_LOG("Assembly : solve succeeded — dof=" << lastResult_.dof
<< ", " << lastResult_.placements.size() << " placements");
return 0;
}
@@ -275,8 +298,14 @@ void AssemblyObject::updateSolveStatus()
//+1 because there's a grounded joint to origin
lastDoF = (1 + numberOfComponents()) * 6;
if (!solver_ || lastResult_.placements.empty()) {
// 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;
solve();
updating = false;
}
if (!solver_) {
@@ -409,6 +438,8 @@ size_t Assembly::AssemblyObject::numberOfFrames()
void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
{
bundleFixed = true;
dragStepCount_ = 0;
dragStepRejected_ = 0;
auto* solver = getOrCreateSolver();
if (!solver) {
@@ -421,6 +452,7 @@ void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
auto groundedObjs = getGroundedParts();
if (groundedObjs.empty()) {
FC_LOG("Assembly : preDrag skipped — no grounded parts");
bundleFixed = false;
return;
}
@@ -474,6 +506,10 @@ void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
}
}
FC_LOG("Assembly : preDrag — " << dragPartIds.size() << " drag part(s), "
<< joints.size() << " joints, " << ctx.parts.size() << " parts, "
<< ctx.constraints.size() << " constraints");
savePlacementsForUndo();
try {
@@ -482,11 +518,13 @@ void AssemblyObject::preDrag(std::vector<App::DocumentObject*> dragParts)
}
catch (...) {
// If pre_drag fails, we still need to be in a valid state
FC_LOG("Assembly : preDrag — solver pre_drag threw exception");
}
}
void AssemblyObject::doDragStep()
{
dragStepCount_++;
try {
std::vector<KCSolve::SolveResult::PartResult> dragPlacements;
@@ -506,6 +544,10 @@ void AssemblyObject::doDragStep()
lastResult_ = solver_->drag_step(dragPlacements);
if (lastResult_.status == KCSolve::SolveStatus::Failed) {
FC_LOG("Assembly : dragStep #" << dragStepCount_ << " — solver failed");
}
if (validateNewPlacements()) {
setNewPlacements();
@@ -525,9 +567,12 @@ void AssemblyObject::doDragStep()
}
}
}
else {
dragStepRejected_++;
}
}
catch (...) {
// We do nothing if a solve step fails.
FC_LOG("Assembly : dragStep #" << dragStepCount_ << " — exception");
}
}
@@ -639,6 +684,8 @@ bool AssemblyObject::validateNewPlacements()
void AssemblyObject::postDrag()
{
FC_LOG("Assembly : postDrag — " << dragStepCount_ << " steps, "
<< dragStepRejected_ << " rejected");
if (solver_) {
solver_->post_drag();
}
@@ -1068,10 +1115,19 @@ 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:
@@ -1350,6 +1406,23 @@ KCSolve::SolveContext AssemblyObject::buildSolveContext(
ctx.simulation = sp;
}
// Log context summary
{
int nGrounded = 0, nFree = 0, nLimits = 0;
for (const auto& p : ctx.parts) {
if (p.grounded) nGrounded++;
else nFree++;
}
for (const auto& c : ctx.constraints) {
if (!c.limits.empty()) nLimits++;
}
FC_LOG("Assembly : buildSolveContext — "
<< nGrounded << " grounded + " << nFree << " free parts, "
<< ctx.constraints.size() << " constraints"
<< (nLimits ? (std::string(", ") + std::to_string(nLimits) + " with limits") : "")
<< (ctx.bundle_fixed ? ", bundle_fixed=true" : ""));
}
return ctx;
}

View File

@@ -280,6 +280,10 @@ private:
bool bundleFixed;
// Drag diagnostic counters (reset in preDrag, reported in postDrag)
int dragStepCount_ = 0;
int dragStepRejected_ = 0;
int lastDoF;
bool lastHasConflict;
bool lastHasRedundancies;

View File

@@ -54,10 +54,56 @@
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) {
@@ -164,6 +210,56 @@ 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,6 +148,7 @@ 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,6 +191,90 @@ 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,34 +162,28 @@ 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())
# 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",
)
for name, data in entries.items():
if name not in existing:
zf.writestr(name, data)
existing.add(name)
except Exception as e:
FreeCAD.Console.PrintWarning(
f"kc_format: failed to update .kc silo/ entries: {e}\n"
@@ -209,17 +203,36 @@ def update_manifest_fields(filename, updates):
return
if not os.path.isfile(filename):
return
import shutil
import tempfile
try:
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",
)
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
except Exception as e:
FreeCAD.Console.PrintWarning(f"kc_format: failed to update manifest: {e}\n")