Compare commits
20 Commits
fix/assemb
...
fix/distan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0cd3db7e | ||
| cf2fc82eac | |||
|
|
e5b07449d7 | ||
| 58d98c6d92 | |||
|
|
a10b9d9a9f | ||
|
|
d0e6d91642 | ||
|
|
05428f8a1c | ||
|
|
14f314e137 | ||
|
|
30c35af3be | ||
| 441cf9e826 | |||
|
|
c682c5d153 | ||
| f65a4a5e2b | |||
| a445275fd2 | |||
|
|
88efa2a6ae | ||
| 62f077a267 | |||
|
|
b6b0ebb4dc | ||
| a6d0427639 | |||
|
|
5883ac8a0d | ||
| f9b13710f3 | |||
|
|
39e78ee0a2 |
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Submodule mods/solver updated: adaa0f9a69...8e521b4519
@@ -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
|
||||
|
||||
@@ -155,6 +155,7 @@ requirements:
|
||||
- lark
|
||||
- lxml
|
||||
- matplotlib-base
|
||||
- networkx
|
||||
- nine
|
||||
- noqt5
|
||||
- numpy>=1.26,<2
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user