diff --git a/cMake/FreeCAD_Helpers/InitializeFreeCADBuildOptions.cmake b/cMake/FreeCAD_Helpers/InitializeFreeCADBuildOptions.cmake
index 6e8c8e42b0..a542ba8ddf 100644
--- a/cMake/FreeCAD_Helpers/InitializeFreeCADBuildOptions.cmake
+++ b/cMake/FreeCAD_Helpers/InitializeFreeCADBuildOptions.cmake
@@ -17,6 +17,8 @@ macro(InitializeFreeCADBuildOptions)
option(FREECAD_PARALLEL_LINK_JOBS "Linkage jobs pool size to fit memory limitations.")
option(BUILD_WITH_CONDA "Set ON if you build FreeCAD with conda" OFF)
option(BUILD_DYNAMIC_LINK_PYTHON "If OFF extension-modules do not link against python-libraries" ON)
+ option(BUILD_TRACY_FRAME_PROFILER "If ON then enables support for the Tracy frame profiler" OFF)
+
option(INSTALL_TO_SITEPACKAGES "If ON the freecad root namespace (python) is installed into python's site-packages" ON)
option(INSTALL_PREFER_SYMLINKS "If ON then fc_copy_sources macro will create symlinks instead of copying files" OFF)
option(OCCT_CMAKE_FALLBACK "disable usage of occt-config files" OFF)
diff --git a/src/3rdParty/CMakeLists.txt b/src/3rdParty/CMakeLists.txt
index e5dec18854..71b368b902 100644
--- a/src/3rdParty/CMakeLists.txt
+++ b/src/3rdParty/CMakeLists.txt
@@ -8,8 +8,17 @@ add_subdirectory(libE57Format)
if (BUILD_ASSEMBLY AND NOT FREECAD_USE_EXTERNAL_ONDSELSOLVER)
if( NOT EXISTS "${CMAKE_SOURCE_DIR}/src/3rdParty/OndselSolver/CMakeLists.txt" )
- message( SEND_ERROR "The OndselSolver git submodule is not available. Please run
+ message(FATAL_ERROR "The OndselSolver git submodule is not available. Please run
git submodule update --init" )
endif()
add_subdirectory(OndselSolver)
endif()
+
+if (BUILD_TRACY_FRAME_PROFILER)
+ if( NOT EXISTS "${CMAKE_SOURCE_DIR}/src/3rdParty/tracy/CMakeLists.txt" )
+ message(FATAL_ERROR "The Tracy git directory is not available. Please clone it manually." )
+ endif()
+
+ set(TRACY_STATIC OFF)
+ add_subdirectory(tracy)
+endif()
diff --git a/src/3rdParty/GSL b/src/3rdParty/GSL
index b39e7e4b09..2828399820 160000
--- a/src/3rdParty/GSL
+++ b/src/3rdParty/GSL
@@ -1 +1 @@
-Subproject commit b39e7e4b0987859f5b19ff7686b149c916588658
+Subproject commit 2828399820ef4928cc89b65605dca5dc68efca6e
diff --git a/src/App/CMakeLists.txt b/src/App/CMakeLists.txt
index 697512470b..519d8f01ec 100644
--- a/src/App/CMakeLists.txt
+++ b/src/App/CMakeLists.txt
@@ -3,6 +3,10 @@ if(WIN32)
add_definitions(-DBOOST_DYN_LINK)
endif(WIN32)
+if(BUILD_TRACY_FRAME_PROFILER)
+ add_definitions(-DBUILD_TRACY_FRAME_PROFILER)
+endif()
+
if(FREECAD_RELEASE_SEH)
add_definitions(-DHAVE_SEH)
endif(FREECAD_RELEASE_SEH)
@@ -71,6 +75,10 @@ set(FreeCADApp_LIBS
fmt::fmt
)
+if(BUILD_TRACY_FRAME_PROFILER)
+ list(APPEND FreeCADApp_LIBS TracyClient)
+endif()
+
include_directories(
${QtCore_INCLUDE_DIRS}
${QtXml_INCLUDE_DIRS}
diff --git a/src/App/Document.cpp b/src/App/Document.cpp
index d843343c9b..158fa6d9db 100644
--- a/src/App/Document.cpp
+++ b/src/App/Document.cpp
@@ -94,6 +94,7 @@ recompute path. Also, it enables more complicated dependencies beyond trees.
#include
#include
#include
+#include
#include
#include
#include
@@ -2963,6 +2964,8 @@ int Document::recompute(const std::vector& objs,
bool* hasError,
int options)
{
+ ZoneScoped;
+
if (d->undoing || d->rollback) {
if (FC_LOG_INSTANCE.isEnabled(FC_LOGLEVEL_LOG)) {
FC_WARN("Ignore document recompute on undo/redo");
diff --git a/src/Base/CMakeLists.txt b/src/Base/CMakeLists.txt
index d8d3868f6b..5f894d5be8 100644
--- a/src/Base/CMakeLists.txt
+++ b/src/Base/CMakeLists.txt
@@ -5,6 +5,10 @@ if(WIN32)
add_definitions(-DZIPIOS_UTF8)
endif(WIN32)
+if(BUILD_TRACY_FRAME_PROFILER)
+ add_definitions(-DBUILD_TRACY_FRAME_PROFILER)
+endif()
+
include_directories(
${CMAKE_BINARY_DIR}/src
${CMAKE_SOURCE_DIR}/src
@@ -65,6 +69,11 @@ endif(MSVC)
include_directories(
${QtCore_INCLUDE_DIRS}
)
+
+if(BUILD_TRACY_FRAME_PROFILER)
+ list(APPEND FreeCADBase_LIBS TracyClient)
+endif()
+
list(APPEND FreeCADBase_LIBS ${QtCore_LIBRARIES})
list(APPEND FreeCADBase_LIBS fmt::fmt)
diff --git a/src/Base/Profiler.h b/src/Base/Profiler.h
new file mode 100644
index 0000000000..3a517c2e97
--- /dev/null
+++ b/src/Base/Profiler.h
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: LGPL-2.1-or-later
+/****************************************************************************
+ * *
+ * Copyright (c) 2025 Joao Matos *
+ * *
+ * This file is part of FreeCAD. *
+ * *
+ * FreeCAD is free software: you can redistribute it and/or modify it *
+ * under the terms of the GNU Lesser General Public License as *
+ * published by the Free Software Foundation, either version 2.1 of the *
+ * License, or (at your option) any later version. *
+ * *
+ * FreeCAD is distributed in the hope that it will be useful, but *
+ * WITHOUT ANY WARRANTY; without even the implied warranty of *
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+ * Lesser General Public License for more details. *
+ * *
+ * You should have received a copy of the GNU Lesser General Public *
+ * License along with FreeCAD. If not, see *
+ * . *
+ * *
+ ***************************************************************************/
+
+#ifdef TRACY_ENABLE
+#include
+#else
+#define TracyNoop
+
+#define ZoneNamed(x, y)
+#define ZoneNamedN(x, y, z)
+#define ZoneNamedC(x, y, z)
+#define ZoneNamedNC(x, y, z, w)
+
+#define ZoneTransient(x, y)
+#define ZoneTransientN(x, y, z)
+
+#define ZoneScoped
+#define ZoneScopedN(x)
+#define ZoneScopedC(x)
+#define ZoneScopedNC(x, y)
+
+#define ZoneText(x, y)
+#define ZoneTextV(x, y, z)
+#define ZoneTextF(x, ...)
+#define ZoneTextVF(x, y, ...)
+#define ZoneName(x, y)
+#define ZoneNameV(x, y, z)
+#define ZoneNameF(x, ...)
+#define ZoneNameVF(x, y, ...)
+#define ZoneColor(x)
+#define ZoneColorV(x, y)
+#define ZoneValue(x)
+#define ZoneValueV(x, y)
+#define ZoneIsActive false
+#define ZoneIsActiveV(x) false
+
+#define FrameMark
+#define FrameMarkNamed(x)
+#define FrameMarkStart(x)
+#define FrameMarkEnd(x)
+
+#define FrameImage(x, y, z, w, a)
+
+#define TracyLockable(type, varname) type varname
+#define TracyLockableN(type, varname, desc) type varname
+#define TracySharedLockable(type, varname) type varname
+#define TracySharedLockableN(type, varname, desc) type varname
+#define LockableBase(type) type
+#define SharedLockableBase(type) type
+#define LockMark(x) (void)x
+#define LockableName(x, y, z)
+
+#define TracyPlot(x, y)
+#define TracyPlotConfig(x, y, z, w, a)
+
+#define TracyMessage(x, y)
+#define TracyMessageL(x)
+#define TracyMessageC(x, y, z)
+#define TracyMessageLC(x, y)
+#define TracyAppInfo(x, y)
+
+#define TracyAlloc(x, y)
+#define TracyFree(x)
+#define TracyMemoryDiscard(x)
+#define TracySecureAlloc(x, y)
+#define TracySecureFree(x)
+#define TracySecureMemoryDiscard(x)
+
+#define TracyAllocN(x, y, z)
+#define TracyFreeN(x, y)
+#define TracySecureAllocN(x, y, z)
+#define TracySecureFreeN(x, y)
+
+#define ZoneNamedS(x, y, z)
+#define ZoneNamedNS(x, y, z, w)
+#define ZoneNamedCS(x, y, z, w)
+#define ZoneNamedNCS(x, y, z, w, a)
+
+#define ZoneTransientS(x, y, z)
+#define ZoneTransientNS(x, y, z, w)
+
+#define ZoneScopedS(x)
+#define ZoneScopedNS(x, y)
+#define ZoneScopedCS(x, y)
+#define ZoneScopedNCS(x, y, z)
+
+#define TracyAllocS(x, y, z)
+#define TracyFreeS(x, y)
+#define TracyMemoryDiscardS(x, y)
+#define TracySecureAllocS(x, y, z)
+#define TracySecureFreeS(x, y)
+#define TracySecureMemoryDiscardS(x, y)
+
+#define TracyAllocNS(x, y, z, w)
+#define TracyFreeNS(x, y, z)
+#define TracySecureAllocNS(x, y, z, w)
+#define TracySecureFreeNS(x, y, z)
+
+#define TracyMessageS(x, y, z)
+#define TracyMessageLS(x, y)
+#define TracyMessageCS(x, y, z, w)
+#define TracyMessageLCS(x, y, z)
+
+#define TracySourceCallbackRegister(x, y)
+#define TracyParameterRegister(x, y)
+#define TracyParameterSetup(x, y, z, w)
+#define TracyIsConnected false
+#define TracyIsStarted false
+#define TracySetProgramName(x)
+
+#define TracyFiberEnter(x)
+#define TracyFiberEnterHint(x, y)
+#define TracyFiberLeave
+#endif
diff --git a/src/Gui/Application.cpp b/src/Gui/Application.cpp
index 0bc809964d..d69188b1e9 100644
--- a/src/Gui/Application.cpp
+++ b/src/Gui/Application.cpp
@@ -136,6 +136,9 @@
#include "WidgetFactory.h"
#include "3Dconnexion/navlib/NavlibInterface.h"
+#ifdef BUILD_TRACY_FRAME_PROFILER
+#include
+#endif
using namespace Gui;
using namespace Gui::DockWnd;
diff --git a/src/Gui/CMakeLists.txt b/src/Gui/CMakeLists.txt
index cbd20fd5b3..4554222cba 100644
--- a/src/Gui/CMakeLists.txt
+++ b/src/Gui/CMakeLists.txt
@@ -44,6 +44,10 @@ if (BUILD_ADDONMGR)
add_definitions(-DBUILD_ADDONMGR )
endif(BUILD_ADDONMGR)
+if (BUILD_TRACY_FRAME_PROFILER)
+ add_definitions(-DBUILD_TRACY_FRAME_PROFILER)
+endif()
+
include_directories(
${CMAKE_BINARY_DIR}
${CMAKE_CURRENT_SOURCE_DIR}
@@ -99,6 +103,10 @@ else(MSVC)
)
endif(MSVC)
+if(BUILD_TRACY_FRAME_PROFILER)
+ list(APPEND FreeCADGui_LIBS TracyClient)
+endif()
+
if (TARGET Coin::Coin)
list(APPEND FreeCADGui_LIBS Coin::Coin)
else()
diff --git a/src/Gui/Quarter/QuarterWidget.cpp b/src/Gui/Quarter/QuarterWidget.cpp
index 69d4a12459..3560d595de 100644
--- a/src/Gui/Quarter/QuarterWidget.cpp
+++ b/src/Gui/Quarter/QuarterWidget.cpp
@@ -93,6 +93,8 @@
#include
#include
+#include
+
#include "QuarterWidget.h"
#include "InteractionMode.h"
#include "QuarterP.h"
@@ -839,6 +841,8 @@ void QuarterWidget::resizeEvent(QResizeEvent* event)
*/
void QuarterWidget::paintEvent(QPaintEvent* event)
{
+ ZoneScoped;
+
if (updateDevicePixelRatio()) {
qreal dev_pix_ratio = devicePixelRatio();
int width = static_cast(dev_pix_ratio * this->width());
@@ -986,6 +990,7 @@ QuarterWidget::redraw()
void
QuarterWidget::actualRedraw()
{
+ ZoneScoped;
PRIVATE(this)->sorendermanager->render(PRIVATE(this)->clearwindow,
PRIVATE(this)->clearzbuffer);
}
diff --git a/src/Gui/Quarter/SoQTQuarterAdaptor.cpp b/src/Gui/Quarter/SoQTQuarterAdaptor.cpp
index aae4454ebd..ecfbf9a142 100644
--- a/src/Gui/Quarter/SoQTQuarterAdaptor.cpp
+++ b/src/Gui/Quarter/SoQTQuarterAdaptor.cpp
@@ -47,6 +47,10 @@
#include "SoQTQuarterAdaptor.h"
+#ifdef BUILD_TRACY_FRAME_PROFILER
+#include
+#endif
+
// NOLINTBEGIN
// clang-format off
static unsigned char fps2dfont[][12] = {
@@ -766,6 +770,10 @@ void SIM::Coin3D::Quarter::SoQTQuarterAdaptor::paintEvent(QPaintEvent* event)
double start = SbTime::getTimeOfDay().getValue();
QuarterWidget::paintEvent(event);
this->framesPerSecond = addFrametime(start);
+
+#ifdef BUILD_TRACY_FRAME_PROFILER
+ FrameMark;
+#endif
}
void SIM::Coin3D::Quarter::SoQTQuarterAdaptor::resetFrameCounter()
diff --git a/src/Gui/Stylesheets/FreeCAD Dark.qss b/src/Gui/Stylesheets/FreeCAD Dark.qss
index 7b17dfa0b3..44ba4ba450 100644
--- a/src/Gui/Stylesheets/FreeCAD Dark.qss
+++ b/src/Gui/Stylesheets/FreeCAD Dark.qss
@@ -2885,6 +2885,10 @@ QPushButton[objectName="buttonIFCPropertiesDelete"] {
min-width: 100px;
}
+QGroupBox[objectName="matOpsGrpBox"] QPushButton {
+ min-width: 235px;
+}
+
/* Below is a fix for indentation in properties, but this is a QT 6 bug only and so is disabled since Windows is as I write this still on QT 5. */
/* QTreeView::item:selected:active#groupsTreeView {
background-color: @ThemeAccentColor1;
diff --git a/src/Gui/Stylesheets/FreeCAD Light.qss b/src/Gui/Stylesheets/FreeCAD Light.qss
index 392de79236..1981d29924 100644
--- a/src/Gui/Stylesheets/FreeCAD Light.qss
+++ b/src/Gui/Stylesheets/FreeCAD Light.qss
@@ -2890,6 +2890,10 @@ PushButton[objectName="buttonIFCPropertiesDelete"] {
min-width: 100px;
}
+QGroupBox[objectName="matOpsGrpBox"] QPushButton {
+ min-width: 235px;
+}
+
/* Below is a fix for indentation in properties, but this is a QT 6 bug only and so is disabled since Windows is as I write this still on QT 5. */
/* QTreeView::item:selected:active#groupsTreeView {
background-color: @ThemeAccentColor1;
diff --git a/src/Gui/View3DInventorViewer.cpp b/src/Gui/View3DInventorViewer.cpp
index eae47b843d..eec3f67303 100644
--- a/src/Gui/View3DInventorViewer.cpp
+++ b/src/Gui/View3DInventorViewer.cpp
@@ -45,6 +45,8 @@
# include
# include
# include
+# include
+# include
# include
# include
# include
@@ -94,6 +96,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -430,6 +433,11 @@ void View3DInventorViewer::init()
// setting up the defaults for the spin rotation
initialize();
+#ifdef TRACY_ENABLE
+ SoProfiler::init();
+ SoProfiler::enable(TRUE);
+#endif
+
// NOLINTBEGIN
auto cam = new SoOrthographicCamera;
cam->position = SbVec3f(0, 0, 1);
@@ -571,9 +579,14 @@ void View3DInventorViewer::init()
// the fix and some details what happens behind the scene have a look at this
// https://forum.freecad.org/viewtopic.php?f=10&t=7486&p=74777#p74736
uint32_t id = this->getSoRenderManager()->getGLRenderAction()->getCacheContext();
- this->getSoRenderManager()->setGLRenderAction(new SoBoxSelectionRenderAction);
+ auto boxSelectionAction = new SoBoxSelectionRenderAction;
+ this->getSoRenderManager()->setGLRenderAction(boxSelectionAction);
this->getSoRenderManager()->getGLRenderAction()->setCacheContext(id);
+#ifdef TRACY_ENABLE
+ boxSelectionAction->enableElement(SoProfilerElement::getClassTypeId(), SoProfilerElement::getClassStackIndex());
+#endif
+
// set the transparency and antialiasing settings
getSoRenderManager()->getGLRenderAction()->setTransparencyType(SoGLRenderAction::SORTED_OBJECT_SORTED_TRIANGLE_BLEND);
@@ -2415,6 +2428,8 @@ void View3DInventorViewer::renderGLImage()
// upon spin.
void View3DInventorViewer::renderScene()
{
+ ZoneScoped;
+
// Must set up the OpenGL viewport manually, as upon resize
// operations, Coin won't set it up until the SoGLRenderAction is
// applied again. And since we need to do glClear() before applying
@@ -2434,15 +2449,19 @@ void View3DInventorViewer::renderScene()
glDepthRange(0.1,1.0);
#endif
- // Render our scenegraph with the image.
SoGLRenderAction* glra = this->getSoRenderManager()->getGLRenderAction();
SoState* state = glra->getState();
- SoDevicePixelRatioElement::set(state, devicePixelRatio());
- SoGLWidgetElement::set(state, qobject_cast(this->getGLWidget()));
- SoGLRenderActionElement::set(state, glra);
- SoGLVBOActivatedElement::set(state, this->vboEnabled);
- drawSingleBackground(col);
- glra->apply(this->backgroundroot);
+
+ // Render our scenegraph with the image.
+ {
+ ZoneScopedN("Background");
+ SoDevicePixelRatioElement::set(state, devicePixelRatio());
+ SoGLWidgetElement::set(state, qobject_cast(this->getGLWidget()));
+ SoGLRenderActionElement::set(state, glra);
+ SoGLVBOActivatedElement::set(state, this->vboEnabled);
+ drawSingleBackground(col);
+ glra->apply(this->backgroundroot);
+ }
if (!this->shading) {
state->push();
@@ -2480,7 +2499,10 @@ void View3DInventorViewer::renderScene()
#endif
// Render overlay front scenegraph.
- glra->apply(this->foregroundroot);
+ {
+ ZoneScopedN("Foreground");
+ glra->apply(this->foregroundroot);
+ }
if (this->axiscrossEnabled) {
this->drawAxisCross();
@@ -2498,8 +2520,11 @@ void View3DInventorViewer::renderScene()
printDimension();
- for (auto it : this->graphicsItems) {
- it->paintGL();
+ {
+ ZoneScopedN("Graphics items");
+ for (auto it : this->graphicsItems) {
+ it->paintGL();
+ }
}
//fps rendering
@@ -2661,6 +2686,8 @@ void View3DInventorViewer::selectAll()
bool View3DInventorViewer::processSoEvent(const SoEvent* ev)
{
+ ZoneScoped;
+
if (naviCubeEnabled && naviCube->processSoEvent(ev)) {
return true;
}
diff --git a/src/Mod/BIM/bimcommands/BimMaterial.py b/src/Mod/BIM/bimcommands/BimMaterial.py
index a8f896e24e..29061417e7 100644
--- a/src/Mod/BIM/bimcommands/BimMaterial.py
+++ b/src/Mod/BIM/bimcommands/BimMaterial.py
@@ -121,13 +121,18 @@ class BIM_Material:
buttonClear.clicked.connect(self.onClearSearch)
lay.addLayout(searchLayout)
+ createButtonsLayoutBox = QtGui.QGroupBox(
+ translate("BIM", " Material operations"), self.dlg
+ )
+ createButtonsLayoutBox.setObjectName("matOpsGrpBox")
+ createButtonsLayout = QtGui.QGridLayout()
+
# create
- createLayout = QtGui.QHBoxLayout()
buttonCreate = QtGui.QPushButton(
translate("BIM", "Create new material"), self.dlg
)
buttonCreate.setIcon(QtGui.QIcon(":/icons/Arch_Material.svg"))
- createLayout.addWidget(buttonCreate)
+ createButtonsLayout.addWidget(buttonCreate, 0, 0)
buttonCreate.clicked.connect(self.onCreate)
# create multi
@@ -135,9 +140,8 @@ class BIM_Material:
translate("BIM", "Create new multi-material"), self.dlg
)
buttonMulti.setIcon(QtGui.QIcon(":/icons/Arch_Material_Multi.svg"))
- createLayout.addWidget(buttonMulti)
+ createButtonsLayout.addWidget(buttonMulti, 0, 1)
buttonMulti.clicked.connect(self.onMulti)
- lay.addLayout(createLayout)
# merge dupes
opsLayout = QtGui.QHBoxLayout()
@@ -145,7 +149,7 @@ class BIM_Material:
translate("BIM", "Merge duplicates"), self.dlg
)
buttonMergeDupes.setIcon(QtGui.QIcon(":/icons/view-refresh.svg"))
- opsLayout.addWidget(buttonMergeDupes)
+ createButtonsLayout.addWidget(buttonMergeDupes, 1, 0)
buttonMergeDupes.clicked.connect(self.onMergeDupes)
# delete unused
@@ -153,9 +157,11 @@ class BIM_Material:
translate("BIM", "Delete unused"), self.dlg
)
buttonDeleteUnused.setIcon(QtGui.QIcon(":/icons/delete.svg"))
- opsLayout.addWidget(buttonDeleteUnused)
+ createButtonsLayout.addWidget(buttonDeleteUnused, 1, 1)
buttonDeleteUnused.clicked.connect(self.onDeleteUnused)
- lay.addLayout(opsLayout)
+
+ createButtonsLayoutBox.setLayout(createButtonsLayout)
+ lay.addWidget(createButtonsLayoutBox)
# add standard buttons
buttonBox = QtGui.QDialogButtonBox(self.dlg)
diff --git a/src/Mod/CAM/CAMTests/TestSnapmakerPost.py b/src/Mod/CAM/CAMTests/TestSnapmakerPost.py
new file mode 100644
index 0000000000..bfa832a438
--- /dev/null
+++ b/src/Mod/CAM/CAMTests/TestSnapmakerPost.py
@@ -0,0 +1,307 @@
+#!/usr/bin/env python3
+# ***************************************************************************
+# * Copyright (c) 2025 Clair-Loup Sergent *
+# * *
+# * Licensed under the EUPL-1.2 with the specific provision *
+# * (EUPL articles 14 & 15) that the applicable law is the French law. *
+# * and the Jurisdiction Paris. *
+# * Any redistribution must include the specific provision above. *
+# * *
+# * You may obtain a copy of the Licence at: *
+# * https://joinup.ec.europa.eu/software/page/eupl5 *
+# * *
+# * Unless required by applicable law or agreed to in writing, software *
+# * distributed under the Licence is distributed on an "AS IS" basis, *
+# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or *
+# * implied. See the Licence for the specific language governing *
+# * permissions and limitations under the Licence. *
+# ***************************************************************************
+import re
+
+import FreeCAD
+
+import Path
+import CAMTests.PathTestUtils as PathTestUtils
+from Path.Post.Processor import PostProcessorFactory
+
+Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+Path.Log.trackModule(Path.Log.thisModule())
+
+
+class TestSnapmakerPost(PathTestUtils.PathTestBase):
+ """Test the Snapmaker postprocessor."""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up the test environment"""
+
+ FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "True")
+ cls.doc = FreeCAD.open(FreeCAD.getHomePath() + "/Mod/CAM/CAMTests/boxtest.fcstd")
+ cls.job = cls.doc.getObject("Job")
+ cls.post = PostProcessorFactory.get_post_processor(cls.job, "snapmaker")
+ # locate the operation named "Profile"
+ for op in cls.job.Operations.Group:
+ if op.Label == "Profile":
+ # remember the "Profile" operation
+ cls.profile_op = op
+ return
+
+ @classmethod
+ def tearDownClass(cls):
+ """Tear down the test environment"""
+ FreeCAD.closeDocument(cls.doc.Name)
+ FreeCAD.ConfigSet("SuppressRecomputeRequiredDialog", "")
+
+ def setUp(self):
+ """Unit test init"""
+ # allow a full length "diff" if an error occurs
+ self.maxDiff = None
+ # reinitialize the postprocessor data structures between tests
+ self.post.initialize()
+
+ def tearDown(self):
+ """Unit test tear down"""
+ pass
+
+ def get_gcode(self, ops: [str], arguments: str) -> str:
+ """Get postprocessed gcode from a list of operations and postprocessor arguments"""
+ self.profile_op.Path = Path.Path(ops)
+ self.job.PostProcessorArgs = "--no-show-editor --no-gui --no-thumbnail " + arguments
+ return self.post.export()[0][1]
+
+ def test_general(self):
+ """Test Output Generation"""
+
+ expected_header = """\
+;Header Start
+;header_type: cnc
+;machine: Snapmaker 2 A350(T)
+;Post Processor: Snapmaker_post
+;Cam File: boxtest.fcstd
+;Output Time: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{0,6}
+;thumbnail: deactivated."""
+
+ expected_body = """\
+;Begin preamble
+G90
+G17
+G21
+;Begin operation: Fixture
+;Path: Fixture
+G54
+;End operation: Fixture
+;Begin operation: TC: Default Tool
+;Path: TC: Default Tool
+;TC: Default Tool
+;Begin toolchange
+M5
+M76
+M6 T1
+;End operation: TC: Default Tool
+;Begin operation: Profile
+;Path: Profile
+;End operation: Profile
+;Begin postamble
+M400
+M5
+"""
+
+ # test header and body with comments
+ gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent")
+
+ g_lines = gcode.splitlines()
+ e_lines = expected_header.splitlines() + expected_body.splitlines()
+
+ self.assertTrue(len(g_lines), len(e_lines))
+ for (nbr, exp), line in zip(enumerate(e_lines), g_lines):
+ if exp.startswith(";Output Time:"):
+ self.assertTrue(re.match(exp, line) is not None)
+ else:
+ self.assertTrue(line, exp)
+
+ # test body without header
+ gcode = self.get_gcode([], "--machine=A350 --toolhead=50W --spindle-percent --no-header")
+ self.assertEqual(gcode, expected_body)
+
+ # test body without comments
+ gcode = self.get_gcode(
+ [], "--machine=A350 --toolhead=50W --spindle-percent --no-header --no-comments"
+ )
+ expected = "".join(
+ [line for line in expected_body.splitlines(keepends=True) if not line.startswith(";")]
+ )
+ self.assertEqual(gcode, expected)
+
+ def test_command(self):
+ """Test command Generation"""
+ command = Path.Command("G0 X10 Y20 Z30")
+ expected = "G0 X10.000 Y20.000 Z30.000"
+
+ gcode = self.get_gcode(
+ [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
+ )
+ result = gcode.splitlines()[18]
+ self.assertEqual(result, expected)
+
+ def test_precision(self):
+ """Test Precision"""
+ # test G0 command with precision 2 digits precision
+ command = Path.Command("G0 X10 Y20 Z30")
+ expected = "G0 X10.00 Y20.00 Z30.00"
+
+ gcode = self.get_gcode(
+ [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header --precision=2"
+ )
+ result = gcode.splitlines()[18]
+ self.assertEqual(result, expected)
+
+ def test_lines(self):
+ """Test Line Numbers"""
+ command = Path.Command("G0 X10 Y20 Z30")
+ expected = "N46 G0 X10.000 Y20.000 Z30.000"
+
+ gcode = self.get_gcode(
+ [command],
+ "--machine=A350 --toolhead=50W --spindle-percent --no-header --line-numbers --line-number=10 --line-increment=2",
+ )
+ result = gcode.splitlines()[18]
+ self.assertEqual(result, expected)
+
+ def test_preamble(self):
+ """Test Pre-amble"""
+ gcode = self.get_gcode(
+ [],
+ "--machine=A350 --toolhead=50W --spindle-percent --no-header --preamble='G18 G55' --no-comments",
+ )
+ result = gcode.splitlines()[0]
+ self.assertEqual(result, "G18 G55")
+
+ def test_postamble(self):
+ """Test Post-amble"""
+ gcode = self.get_gcode(
+ [],
+ "--machine=A350 --toolhead=50W --spindle-percent --no-header --postamble='G0 Z50\nM2' --no-comments",
+ )
+ result = gcode.splitlines()[-2]
+ self.assertEqual(result, "G0 Z50")
+ self.assertEqual(gcode.splitlines()[-1], "M2")
+
+ def test_inches(self):
+ """Test inches conversion"""
+
+ command = Path.Command("G0 X10 Y20 Z30")
+
+ # test inches conversion
+ expected = "G0 X0.3937 Y0.7874 Z1.1811"
+ gcode = self.get_gcode(
+ [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header --inches"
+ )
+ self.assertEqual(gcode.splitlines()[3], "G20")
+ result = gcode.splitlines()[18]
+ self.assertEqual(result, expected)
+
+ # test inches conversion with 2 digits precision
+ expected = "G0 X0.39 Y0.79 Z1.18"
+ gcode = self.get_gcode(
+ [command],
+ "--machine=A350 --toolhead=50W --spindle-percent --no-header --inches --precision=2",
+ )
+ result = gcode.splitlines()[18]
+ self.assertEqual(result, expected)
+
+ def test_axis_modal(self):
+ """Test axis modal - Suppress the axis coordinate if the same as previous"""
+
+ c0 = Path.Command("G0 X10 Y20 Z30")
+ c1 = Path.Command("G0 X10 Y30 Z30")
+ expected = "G0 Y30.000"
+
+ gcode = self.get_gcode(
+ [c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header --axis-modal"
+ )
+ result = gcode.splitlines()[19]
+ self.assertEqual(result, expected)
+
+ def test_tool_change(self):
+ """Test tool change"""
+
+ c0 = Path.Command("M6 T2")
+ c1 = Path.Command("M3 S3000")
+
+ gcode = self.get_gcode(
+ [c0, c1], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
+ )
+ self.assertEqual(gcode.splitlines()[19:22], ["M5", "M76", "M6 T2"])
+ self.assertEqual(
+ gcode.splitlines()[22], "M3 P25"
+ ) # no TLO on Snapmaker (G43 inserted after tool change)
+
+ def test_spindle(self):
+ """Test spindle speed conversion from RPM to percents"""
+
+ command = Path.Command("M3 S3600")
+
+ # test 50W toolhead
+ gcode = self.get_gcode(
+ [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
+ )
+ self.assertEqual(gcode.splitlines()[18], "M3 P30")
+
+ # test 200W toolhead
+ gcode = self.get_gcode(
+ [command], "--machine=A350 --toolhead=200W --spindle-percent --no-header"
+ )
+ self.assertEqual(gcode.splitlines()[18], "M3 P20")
+
+ # test custom spindle speed extrema
+ gcode = self.get_gcode(
+ [command],
+ "--machine=A350 --toolhead=200W --spindle-percent --no-header --spindle-speeds=3000,4000",
+ )
+ self.assertEqual(gcode.splitlines()[18], "M3 P90")
+
+ def test_comment(self):
+ """Test comment"""
+
+ command = Path.Command("(comment)")
+ gcode = self.get_gcode(
+ [command], "--machine=A350 --toolhead=50W --spindle-percent --no-header"
+ )
+ result = gcode.splitlines()[18]
+ expected = ";comment"
+ self.assertEqual(result, expected)
+
+ def test_boundaries(self):
+ """Test boundaries check"""
+
+ # check succeeds
+ command = Path.Command("G0 X100 Y-100.5 Z-1")
+
+ gcode = self.get_gcode(
+ [command],
+ "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check",
+ )
+ self.assertTrue(self.post.check_boundaries(gcode.splitlines()))
+
+ # check fails with A350
+ c0 = Path.Command("G01 X100 Y-100.5 Z-1")
+ c1 = Path.Command("G02 Y260")
+ gcode = self.get_gcode(
+ [c0, c1],
+ "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check",
+ )
+ self.assertFalse(self.post.check_boundaries(gcode.splitlines()))
+
+ # check succeed with artisan (which base is bigger)
+ gcode = self.get_gcode(
+ [c0, c1],
+ "--machine=artisan --toolhead=50W --spindle-percent --no-header --boundaries-check",
+ )
+ self.assertTrue(self.post.check_boundaries(gcode.splitlines()))
+
+ # check fails with custom boundaries
+ gcode = self.get_gcode(
+ [c0, c1],
+ "--machine=A350 --toolhead=50W --spindle-percent --no-header --boundaries-check --boundaries='50,400,10'",
+ )
+ self.assertFalse(self.post.check_boundaries(gcode.splitlines()))
diff --git a/src/Mod/CAM/CMakeLists.txt b/src/Mod/CAM/CMakeLists.txt
index de03de7190..5acfaab8be 100644
--- a/src/Mod/CAM/CMakeLists.txt
+++ b/src/Mod/CAM/CMakeLists.txt
@@ -171,6 +171,7 @@ SET(PathPythonPostScripts_SRCS
Path/Post/scripts/rrf_post.py
Path/Post/scripts/slic3r_pre.py
Path/Post/scripts/smoothie_post.py
+ Path/Post/scripts/snapmaker_post.py
Path/Post/scripts/uccnc_post.py
Path/Post/scripts/wedm_post.py
)
@@ -353,6 +354,7 @@ SET(Tests_SRCS
CAMTests/TestRefactoredTestPost.py
CAMTests/TestRefactoredTestPostGCodes.py
CAMTests/TestRefactoredTestPostMCodes.py
+ CAMTests/TestSnapmakerPost.py
CAMTests/Tools/Bit/test-path-tool-bit-bit-00.fctb
CAMTests/Tools/Library/test-path-tool-bit-library-00.fctl
CAMTests/Tools/Shape/test-path-tool-bit-shape-00.fcstd
diff --git a/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py b/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py
new file mode 100644
index 0000000000..80338513fb
--- /dev/null
+++ b/src/Mod/CAM/Path/Post/scripts/snapmaker_post.py
@@ -0,0 +1,642 @@
+#!/usr/bin/env python3
+# A FreeCAD postprocessor targeting Snapmaker machines with CNC capabilities
+# ***************************************************************************
+# * Copyright (c) 2025 Clair-Loup Sergent *
+# * *
+# * Licensed under the EUPL-1.2 with the specific provision *
+# * (EUPL articles 14 & 15) that the applicable law is the French law. *
+# * and the Jurisdiction Paris. *
+# * Any redistribution must include the specific provision above. *
+# * *
+# * You may obtain a copy of the Licence at: *
+# * https://joinup.ec.europa.eu/software/page/eupl5 *
+# * *
+# * Unless required by applicable law or agreed to in writing, software *
+# * distributed under the Licence is distributed on an "AS IS" basis, *
+# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or *
+# * implied. See the Licence for the specific language governing *
+# * permissions and limitations under the Licence. *
+# ***************************************************************************
+
+
+import argparse
+import base64
+import datetime
+import os
+import pathlib
+import re
+import tempfile
+from typing import Any
+
+import FreeCAD
+import Path
+import Path.Post.Processor
+import Path.Post.UtilsArguments
+import Path.Post.UtilsExport
+import Path.Post.Utils
+import Path.Post.UtilsParse
+import Path.Main.Job
+
+translate = FreeCAD.Qt.translate
+
+if DEBUG := False:
+ Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
+ Path.Log.trackModule(Path.Log.thisModule())
+else:
+ Path.Log.setLevel(Path.Log.Level.INFO, Path.Log.thisModule())
+
+SNAPMAKER_MACHINES = dict(
+ original=dict(name="Snapmaker Original", X=90, Y=90, Z=50),
+ original_z_extension=dict(name="Snapmaker Original with Z extension", X=90, Y=90, Z=146),
+ a150=dict(name="A150", X=160, Y=160, Z=90),
+ **dict.fromkeys(("A250", "A250T"), dict(name="Snapmaker 2 A250(T)", X=230, Y=250, Z=180)),
+ **dict.fromkeys(("A350", "A350T"), dict(name="Snapmaker 2 A350(T)", X=320, Y=350, Z=275)),
+ artisan=dict(name="Snapmaker Artisan", X=400, Y=400, Z=400),
+)
+
+SNAPMAKER_TOOLHEADS = {
+ "50W": dict(name="50W CNC module", min=0, max=12000, percent=True),
+ "200W": dict(name="200W CNC module", min=8000, max=18000, percent=False),
+}
+
+
+class CoordinatesAction(argparse.Action):
+ """argparse Action to handle coordinates (x,y,z)"""
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ match = re.match(
+ "^\s*(?P-?\d+\.?\d*),?\s*(?P-?\d+\.?\d*),?\s*(?P-?\d+\.?\d*)\s*$", values
+ )
+ if match:
+ # setattr(namespace, self.dest, 'G0 X{0} Y{1} Z{2}'.format(*match.groups()))
+ params = {key: float(value) for key, value in match.groupdict().items()}
+ setattr(namespace, self.dest, params)
+ else:
+ raise argparse.ArgumentError(None, message="invalid coordinates provided")
+
+
+class ExtremaAction(argparse.Action):
+ """argparse Action to handle integer extrema (min,max)"""
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ if match := re.match("^ *(\d+),? *(\d+) *$", values):
+ # setattr(namespace, self.dest, 'G0 X{0} Y{1} Z{2}'.format(*match.groups()))
+ params = {
+ key: int(value)
+ for key, value in zip(
+ (
+ "min",
+ "max",
+ ),
+ match.groups(),
+ )
+ }
+ setattr(namespace, self.dest, params)
+ else:
+ raise argparse.ArgumentError(None, message="invalid values provided, should be int,int")
+
+
+class Snapmaker(Path.Post.Processor.PostProcessor):
+ """FreeCAD postprocessor targeting Snapmaker machines with CNC capabilities"""
+
+ def __init__(self, job) -> None:
+ super().__init__(
+ job=job,
+ tooltip=translate("CAM", "Snapmaker post processor"),
+ tooltipargs=[""],
+ units="Metric",
+ )
+
+ self.initialize()
+
+ def initialize(self):
+ """initialize values and arguments"""
+ self.values: dict[str, Any] = dict()
+ self.argument_defaults: dict[str, bool] = dict()
+ self.arguments_visible: dict[str, bool] = dict()
+ self.parser = argparse.ArgumentParser()
+
+ self.init_values()
+ self.init_argument_defaults()
+ self.init_arguments_visible()
+ self.parser = self.init_parser(self.values, self.argument_defaults, self.arguments_visible)
+
+ # create another parser with all visible arguments
+ all_arguments_visible = dict()
+ for key in iter(self.arguments_visible):
+ all_arguments_visible[key] = True
+ self.visible_parser = self.init_parser(
+ self.values, self.argument_defaults, all_arguments_visible
+ )
+
+ FreeCAD.Console.PrintLog(f'{self.values["POSTPROCESSOR_FILE_NAME"]}: initialized.\n')
+
+ def init_values(self):
+ """Initialize values that are used throughout the postprocessor."""
+ Path.Post.UtilsArguments.init_shared_values(self.values)
+
+ # shared values
+ self.values["POSTPROCESSOR_FILE_NAME"] = __name__
+ self.values["COMMENT_SYMBOL"] = ";"
+ self.values["ENABLE_MACHINE_SPECIFIC_COMMANDS"] = True
+ self.values["END_OF_LINE_CHARACTERS"] = "\n"
+ self.values["FINISH_LABEL"] = "End"
+ self.values["LINE_INCREMENT"] = 1
+ self.values["MACHINE_NAME"] = "Generic Snapmaker"
+ self.values["MODAL"] = False
+ self.values["OUTPUT_PATH_LABELS"] = True
+ self.values["OUTPUT_HEADER"] = (
+ True # remove FreeCAD standard header and use a custom Snapmaker Header
+ )
+ self.values["OUTPUT_TOOL_CHANGE"] = True
+ self.values["PARAMETER_ORDER"] = [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "B",
+ "C",
+ "I",
+ "J",
+ "F",
+ "S",
+ "T",
+ "Q",
+ "R",
+ "L",
+ "H",
+ "D",
+ "P",
+ "O",
+ ]
+ self.values["PREAMBLE"] = f"""G90\nG17"""
+ self.values["PRE_OPERATION"] = """"""
+ self.values["POST_OPERATION"] = """"""
+ self.values["POSTAMBLE"] = """M400\nM5"""
+ self.values["SHOW_MACHINE_UNITS"] = False
+ self.values["SPINDLE_DECIMALS"] = 0
+ self.values["SPINDLE_WAIT"] = 4.0
+ self.values["TOOL_CHANGE"] = "M76" # handle tool change by inserting an HMI pause
+ self.values["TRANSLATE_DRILL_CYCLES"] = True # drill cycle gcode must be translated
+ self.values["USE_TLO"] = False # G43 is not handled.
+
+ # snapmaker values
+ self.values["THUMBNAIL"] = True
+ self.values["BOUNDARIES"] = None
+ self.values["BOUNDARIES_CHECK"] = False
+ self.values["MACHINES"] = SNAPMAKER_MACHINES
+ self.values["TOOLHEADS"] = SNAPMAKER_TOOLHEADS
+ # default toolhead is 50W (the weakest one)
+ self.values["DEFAULT_TOOLHEAD"] = "50W"
+ self.values["TOOLHEAD_NAME"] = SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["name"]
+ self.values["SPINDLE_SPEEDS"] = dict(
+ min=SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["min"],
+ max=SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]]["max"],
+ )
+ self.values["SPINDLE_PERCENT"] = SNAPMAKER_TOOLHEADS[self.values["DEFAULT_TOOLHEAD"]][
+ "percent"
+ ]
+
+ def init_argument_defaults(self) -> None:
+ """Initialize which arguments (in a pair) are shown as the default argument."""
+ Path.Post.UtilsArguments.init_argument_defaults(self.argument_defaults)
+
+ self.argument_defaults["tlo"] = False
+ self.argument_defaults["translate-drill"] = True
+
+ # snapmaker arguments
+ self.argument_defaults["thumbnail"] = True
+ self.argument_defaults["gui"] = True
+ self.argument_defaults["boundaries-check"] = True
+ self.argument_defaults["spindle-percent"] = True
+
+ def init_arguments_visible(self) -> None:
+ """Initialize which argument pairs are visible in TOOLTIP_ARGS."""
+ Path.Post.UtilsArguments.init_arguments_visible(self.arguments_visible)
+
+ self.arguments_visible["axis-modal"] = False
+ self.arguments_visible["header"] = False
+ self.arguments_visible["return-to"] = True
+ self.arguments_visible["tlo"] = False
+ self.arguments_visible["tool_change"] = True
+ self.arguments_visible["translate-drill"] = False
+ self.arguments_visible["wait-for-spindle"] = True
+
+ # snapmaker arguments (for record, always visible)
+ self.arguments_visible["thumbnail"] = True
+ self.arguments_visible["gui"] = True
+ self.arguments_visible["boundaries"] = True
+ self.arguments_visible["boundaries-check"] = True
+ self.arguments_visible["machine"] = True
+ self.arguments_visible["toolhead"] = True
+ self.arguments_visible["line-increment"] = True
+ self.arguments_visible["spindle-speeds"] = True
+
+ def init_parser(self, values, argument_defaults, arguments_visible) -> argparse.ArgumentParser:
+ """Initialize the postprocessor arguments parser"""
+ parser = Path.Post.UtilsArguments.init_shared_arguments(
+ values, argument_defaults, arguments_visible
+ )
+
+ # snapmaker custom arguments
+ group = parser.add_argument_group("Snapmaker only arguments")
+ # add_flag_type_arguments function is not used as its behavior is inconsistent with argparse
+ # handle thumbnail generation
+ group.add_argument(
+ "--thumbnail",
+ action="store_true",
+ default=argument_defaults["thumbnail"],
+ help="Include a thumbnail (require --gui)",
+ )
+ group.add_argument(
+ "--no-thumbnail", action="store_false", dest="thumbnail", help="Remove thumbnail"
+ )
+
+ group.add_argument(
+ "--gui",
+ action="store_true",
+ default=argument_defaults["gui"],
+ help="allow the postprocessor to execute GUI methods",
+ )
+ group.add_argument(
+ "--no-gui",
+ action="store_false",
+ dest="gui",
+ help="Execute postprocessor without requiring GUI",
+ )
+
+ group.add_argument(
+ "--boundaries-check",
+ action="store_true",
+ default=argument_defaults["boundaries-check"],
+ help="check boundaries according to the machine build area",
+ )
+ group.add_argument(
+ "--no-boundaries-check",
+ action="store_false",
+ dest="boundaries_check",
+ help="Disable boundaries check",
+ )
+
+ group.add_argument(
+ "--boundaries",
+ action=CoordinatesAction,
+ default=None,
+ help='Custom boundaries (e.g. "100, 200, 300"). Overrides --machine',
+ )
+
+ group.add_argument(
+ "--machine",
+ default=None,
+ choices=self.values["MACHINES"].keys(),
+ help=f"Snapmaker machine",
+ )
+
+ group.add_argument(
+ "--toolhead",
+ default=None,
+ choices=self.values["TOOLHEADS"].keys(),
+ help=f"Snapmaker toolhead",
+ )
+
+ group.add_argument(
+ "--spindle-speeds",
+ action=ExtremaAction,
+ default=None,
+ help="Set minimum/maximum spindle speeds as --spindle-speeds='min,max'",
+ )
+
+ group.add_argument(
+ "--spindle-percent",
+ action="store_true",
+ default=argument_defaults["spindle-percent"],
+ help="use percent as toolhead spindle speed unit",
+ )
+ group.add_argument(
+ "--spindle-rpm",
+ action="store_false",
+ dest="spindle_percent",
+ help="Use RPM as toolhead spindle speed unit",
+ )
+
+ group.add_argument(
+ "--line-number",
+ type=int,
+ default=self.values["line_number"],
+ help="Set the line starting value",
+ )
+
+ group.add_argument(
+ "--line-increment",
+ type=int,
+ default=self.values["LINE_INCREMENT"],
+ help="Set the line increment value",
+ )
+
+ return parser
+
+ def process_arguments(self, filename: str = "-") -> (bool, str | argparse.Namespace):
+ """Process any arguments to the postprocessor."""
+ (flag, args) = Path.Post.UtilsArguments.process_shared_arguments(
+ self.values, self.parser, self._job.PostProcessorArgs, self.visible_parser, filename
+ )
+ if flag: # process extra arguments only if flag is True
+ self._units = self.values["UNITS"]
+
+ if args.machine:
+ machine = self.values["MACHINES"][args.machine]
+ self.values["MACHINE_NAME"] = machine["name"]
+ self.values["BOUNDARIES"] = {key: machine[key] for key in ("X", "Y", "Z")}
+
+ if args.boundaries: # may override machine boundaries, which is expected
+ self.values["BOUNDARIES"] = args.boundaries
+
+ if args.toolhead:
+ toolhead = self.values["TOOLHEADS"][args.toolhead]
+ self.values["TOOLHEAD_NAME"] = toolhead["name"]
+ else:
+ FreeCAD.Console.PrintWarning(
+ f'No toolhead selected, using default ({self.values["TOOLHEAD_NAME"]}). '
+ f"Consider adding --toolhead\n"
+ )
+ toolhead = self.values["TOOLHEADS"][self.values["DEFAULT_TOOLHEAD"]]
+
+ self.values["SPINDLE_SPEEDS"] = {key: toolhead[key] for key in ("min", "max")}
+
+ if args.spindle_speeds: # may override toolhead value, which is expected
+ self.values["SPINDLE_SPEEDS"] = args.spindle_speeds
+
+ if args.spindle_percent is not None:
+ if toolhead["percent"] is True:
+ self.values["SPINDLE_PERCENT"] = True
+ if args.spindle_percent is False:
+ FreeCAD.Console.PrintWarning(
+ f"Toolhead does not handle RPM spindle speed, using percents instead.\n"
+ )
+ else:
+ self.values["SPINDLE_PERCENT"] = args.spindle_percent
+
+ self.values["THUMBNAIL"] = args.thumbnail
+ self.values["ALLOW_GUI"] = args.gui
+ self.values["line_number"] = args.line_number
+ self.values["LINE_INCREMENT"] = args.line_increment
+
+ if args.boundaries_check and not self.values["BOUNDARIES"]:
+ FreeCAD.Console.PrintError("Boundary check skipped: no valid boundaries supplied\n")
+ self.values["BOUNDARIES_CHECK"] = False
+ else:
+ self.values["BOUNDARIES_CHECK"] = args.boundaries_check
+
+ return flag, args
+
+ def process_postables(self, filename: str = "-") -> [(str, str)]:
+ """process job sections to gcode"""
+ sections: [(str, str)] = list()
+
+ postables = self._buildPostList()
+
+ # basic filename handling
+ if len(postables) > 1 and filename != "-":
+ filename = pathlib.Path(filename)
+ filename = str(filename.with_stem(filename.stem + "_{name}"))
+
+ for name, objects in postables:
+ gcode = self.export_common(objects, filename.format(name=name))
+ sections.append((name, gcode))
+
+ return sections
+
+ def get_thumbnail(self) -> str:
+ """generate a thumbnail of the job from the given objects"""
+ if self.values["THUMBNAIL"] is False:
+ return "thumbnail: deactivated."
+
+ if not (self.values["ALLOW_GUI"] and FreeCAD.GuiUp):
+ FreeCAD.Console.PrintError(
+ "GUI access required: thumbnail generation skipped. Consider adding --gui\n"
+ )
+ return "thumbnail: GUI required."
+
+ # get FreeCAD references
+ import FreeCADGui
+
+ view = FreeCADGui.activeDocument().activeView()
+ selection = FreeCADGui.Selection
+
+ # save current selection
+ selected = [
+ obj.Object for obj in selection.getCompleteSelection() if hasattr(obj, "Object")
+ ]
+ selection.clearSelection()
+
+ # clear view
+ FreeCADGui.runCommand("Std_SelectAll", 0)
+ all = []
+ for obj in selection.getCompleteSelection():
+ if hasattr(obj, "Object"):
+ all.append((obj.Object, obj.Object.Visibility))
+ obj.Object.ViewObject.hide()
+
+ # select models to display
+ for model in self._job.Model.Group:
+ model.ViewObject.show()
+ selection.addSelection(model.Document.Name, model.Name)
+ view.fitAll() # center selection
+ view.viewIsometric() # display as isometric
+ selection.clearSelection()
+
+ # generate thumbnail
+ with tempfile.TemporaryDirectory() as temp:
+ path = os.path.join(temp, "thumbnail.png")
+ view.saveImage(path, 720, 480, "Transparent")
+ with open(path, "rb") as file:
+ data = file.read()
+
+ # restore view
+ for obj, visibility in all:
+ if visibility:
+ obj.ViewObject.show()
+
+ # restore selection
+ for obj in selected:
+ selection.clearSelection()
+ selection.addSelection(obj.Document.Name, obj.Name)
+
+ return f"thumbnail: data:image/png;base64,{base64.b64encode(data).decode()}"
+
+ def output_header(self, gcode: [[]]):
+ """custom method derived from Path.Post.UtilsExport.output_header"""
+ cam_file: str
+ comment: str
+ nl: str = "\n"
+
+ if not self.values["OUTPUT_HEADER"]:
+ return
+
+ def add_comment(text):
+ com = Path.Post.UtilsParse.create_comment(self.values, text)
+ gcode.append(
+ f'{Path.Post.UtilsParse.linenumber(self.values)}{com}{self.values["END_OF_LINE_CHARACTERS"]}'
+ )
+
+ add_comment("Header Start")
+ add_comment("header_type: cnc")
+ add_comment(f'machine: {self.values["MACHINE_NAME"]}')
+ comment = Path.Post.UtilsParse.create_comment(
+ self.values, f'Post Processor: {self.values["POSTPROCESSOR_FILE_NAME"]}'
+ )
+ gcode.append(f"{Path.Post.UtilsParse.linenumber(self.values)}{comment}{nl}")
+ if FreeCAD.ActiveDocument:
+ cam_file = os.path.basename(FreeCAD.ActiveDocument.FileName)
+ else:
+ cam_file = ""
+ add_comment(f"Cam File: {cam_file}")
+ add_comment(f"Output Time: {datetime.datetime.now()}")
+ add_comment(self.get_thumbnail())
+
+ def convert_spindle(self, gcode: [str]) -> [str]:
+ """convert spindle speed values from RPM to percent (%) (M3/M4 commands)"""
+ if self.values["SPINDLE_PERCENT"] is False:
+ return
+
+ # TODO: check if percentage covers range 0-max (most probable) or min-max (200W has a documented min speed)
+ for index, commandline in enumerate(
+ gcode
+ ): # .split(self.values["END_OF_LINE_CHARACTERS"]):
+ if match := re.match("(?PM0?[34])\D.*(?PS\d+.?\d*)", commandline):
+ percent = (
+ float(match.group("spindle")[1:]) * 100 / self.values["SPINDLE_SPEEDS"]["max"]
+ )
+ gcode[index] = (
+ gcode[index][: match.span("spindle")[0]]
+ + f'P{percent:.{self.values["SPINDLE_DECIMALS"]}f}'
+ + gcode[index][match.span("spindle")[1] :]
+ )
+ return gcode
+
+ def check_boundaries(self, gcode: [str]) -> bool:
+ """Check boundaries and return whether it succeeded"""
+ status = True
+ FreeCAD.Console.PrintLog("Boundaries check\n")
+
+ extrema = dict(X=[0, 0], Y=[0, 0], Z=[0, 0])
+ position = dict(X=0, Y=0, Z=0)
+ relative = False
+
+ for index, commandline in enumerate(gcode):
+ if re.match("G90(?:\D|$)", commandline):
+ relative = False
+ elif re.match("G91(?:\D|$)", commandline):
+ relative = True
+ elif re.match("G0?[12](?:\D|$)", commandline):
+ for axis, value in re.findall(
+ "(?P[XYZ])(?P-?\d+\.?\d*)(?:\D|$)", commandline
+ ):
+ if relative:
+ position[axis] += float(value)
+ else:
+ position[axis] = float(value)
+ extrema[axis][0] = max(extrema[axis][0], position[axis])
+ extrema[axis][1] = min(extrema[axis][1], position[axis])
+
+ for axis in extrema.keys():
+ if abs(extrema[axis][0] - extrema[axis][1]) > self.values["BOUNDARIES"][axis]:
+ # gcode.insert(0, f';WARNING: Boundary check: job exceeds machine limit on {axis} axis{self.values["END_OF_LINE_CHARACTERS"]}')
+ FreeCAD.Console.PrintWarning(
+ f"Boundary check: job exceeds machine limit on {axis} axis\n"
+ )
+ status = False
+
+ return status
+
+ def export_common(self, objects: list, filename: str | pathlib.Path) -> str:
+ """custom method derived from Path.Post.UtilsExport.export_common"""
+ final: str
+ gcode: [[]] = []
+ result: bool
+
+ for obj in objects:
+ if not hasattr(obj, "Path"):
+ print(f"The object {obj.Name} is not a path.")
+ print("Please select only path and Compounds.")
+ return ""
+
+ Path.Post.UtilsExport.check_canned_cycles(self.values)
+ self.output_header(gcode)
+ Path.Post.UtilsExport.output_safetyblock(self.values, gcode)
+ Path.Post.UtilsExport.output_tool_list(self.values, gcode, objects)
+ Path.Post.UtilsExport.output_preamble(self.values, gcode)
+ Path.Post.UtilsExport.output_motion_mode(self.values, gcode)
+ Path.Post.UtilsExport.output_units(self.values, gcode)
+
+ for obj in objects:
+ # Skip inactive operations
+ if hasattr(obj, "Active") and not obj.Active:
+ continue
+ if hasattr(obj, "Base") and hasattr(obj.Base, "Active") and not obj.Base.Active:
+ continue
+ coolant_mode = Path.Post.UtilsExport.determine_coolant_mode(obj)
+ Path.Post.UtilsExport.output_start_bcnc(self.values, gcode, obj)
+ Path.Post.UtilsExport.output_preop(self.values, gcode, obj)
+ Path.Post.UtilsExport.output_coolant_on(self.values, gcode, coolant_mode)
+ # output the G-code for the group (compound) or simple path
+ Path.Post.UtilsParse.parse_a_group(self.values, gcode, obj)
+
+ Path.Post.UtilsExport.output_postop(self.values, gcode, obj)
+ Path.Post.UtilsExport.output_coolant_off(self.values, gcode, coolant_mode)
+
+ Path.Post.UtilsExport.output_return_to(self.values, gcode)
+ #
+ # This doesn't make sense to me. It seems that both output_start_bcnc and
+ # output_end_bcnc should be in the for loop or both should be out of the
+ # for loop. However, that is the way that grbl post code was written, so
+ # for now I will leave it that way until someone has time to figure it out.
+ #
+ Path.Post.UtilsExport.output_end_bcnc(self.values, gcode)
+ Path.Post.UtilsExport.output_postamble_header(self.values, gcode)
+ Path.Post.UtilsExport.output_tool_return(self.values, gcode)
+ Path.Post.UtilsExport.output_safetyblock(self.values, gcode)
+ Path.Post.UtilsExport.output_postamble(self.values, gcode)
+ gcode = self.convert_spindle(gcode)
+
+ if self.values["BOUNDARIES_CHECK"]:
+ self.check_boundaries(gcode)
+
+ final = "".join(gcode)
+
+ if FreeCAD.GuiUp and self.values["SHOW_EDITOR"]:
+ # size limit removed as irrelevant on my computer - see if issues occur
+ dia = Path.Post.Utils.GCodeEditorDialog()
+ dia.editor.setText(final)
+ result = dia.exec_()
+ if result:
+ final = dia.editor.toPlainText()
+
+ if not filename == "-":
+ with open(
+ filename, "w", encoding="utf-8", newline=self.values["END_OF_LINE_CHARACTERS"]
+ ) as gfile:
+ gfile.write(final)
+
+ return final
+
+ def export(self, filename: str | pathlib.Path = "-"):
+ """process gcode and export"""
+ (flag, args) = self.process_arguments()
+ if flag:
+ return self.process_postables(filename)
+ else:
+ return [("allitems", args)]
+
+ @property
+ def tooltip(self) -> str:
+ tooltip = "Postprocessor of the FreeCAD CAM workbench for the Snapmaker machines"
+ return tooltip
+
+ @property
+ def tooltipArgs(self) -> str:
+ return self.parser.format_help()
+
+
+if __name__ == "__main__":
+ Snapmaker(None).visible_parser.format_help()
diff --git a/src/Mod/CAM/TestCAMApp.py b/src/Mod/CAM/TestCAMApp.py
index 80554918d9..da31bb3255 100644
--- a/src/Mod/CAM/TestCAMApp.py
+++ b/src/Mod/CAM/TestCAMApp.py
@@ -80,6 +80,7 @@ from CAMTests.TestRefactoredMach3Mach4Post import TestRefactoredMach3Mach4Post
from CAMTests.TestRefactoredTestPost import TestRefactoredTestPost
from CAMTests.TestRefactoredTestPostGCodes import TestRefactoredTestPostGCodes
from CAMTests.TestRefactoredTestPostMCodes import TestRefactoredTestPostMCodes
+from CAMTests.TestSnapmakerPost import TestSnapmakerPost
# dummy usage to get flake8 and lgtm quiet
False if TestCAMSanity.__name__ else True
@@ -136,3 +137,4 @@ False if TestRefactoredMach3Mach4Post.__name__ else True
False if TestRefactoredTestPost.__name__ else True
False if TestRefactoredTestPostGCodes.__name__ else True
False if TestRefactoredTestPostMCodes.__name__ else True
+False if TestSnapmakerPost.__name__ else True
diff --git a/src/Mod/Draft/draftguitools/gui_fillets.py b/src/Mod/Draft/draftguitools/gui_fillets.py
index 03a0f63439..187c2e75ac 100644
--- a/src/Mod/Draft/draftguitools/gui_fillets.py
+++ b/src/Mod/Draft/draftguitools/gui_fillets.py
@@ -74,7 +74,6 @@ class Fillet(gui_base_original.Creator):
super().Activated(name=name)
if self.ui:
- self.rad = params.get_param("FilletRadius")
self.chamfer = params.get_param("FilletChamferMode")
self.delete = params.get_param("FilletDeleteMode")
label = translate("draft", "Fillet radius")
@@ -86,7 +85,8 @@ class Fillet(gui_base_original.Creator):
self.ui.sourceCmd = self
self.ui.labelRadius.setText(label)
self.ui.radiusValue.setToolTip(tooltip)
- self.ui.setRadiusValue(self.rad, "Length")
+ self.ui.radius = params.get_param("FilletRadius")
+ self.ui.setRadiusValue(self.ui.radius, "Length")
self.ui.check_delete = self.ui._checkbox("isdelete",
self.ui.layout,
checked=self.delete)
@@ -136,13 +136,10 @@ class Fillet(gui_base_original.Creator):
params.set_param("FilletChamferMode", self.chamfer)
def numericRadius(self, rad):
- """Validate the entry radius in the user interface.
-
- This function is called by the toolbar or taskpanel interface
- when a valid radius has been entered in the input field.
+ """This function is called by the taskpanel interface
+ when a radius has been entered in the input field.
"""
- self.rad = rad
- params.set_param("FilletRadius", self.rad)
+ params.set_param("FilletRadius", rad)
self.draw_arc(rad, self.chamfer, self.delete)
def draw_arc(self, rad, chamfer, delete):
diff --git a/src/Mod/Measure/Gui/Resources/translations/Measure_es-ES.ts b/src/Mod/Measure/Gui/Resources/translations/Measure_es-ES.ts
new file mode 100644
index 0000000000..fa64c80774
--- /dev/null
+++ b/src/Mod/Measure/Gui/Resources/translations/Measure_es-ES.ts
@@ -0,0 +1,186 @@
+
+
+
+
+ Gui::TaskMeasure
+
+
+ Measurement
+ Medición
+
+
+
+ Show Delta:
+ Mostrar delta:
+
+
+
+ Auto Save
+ Guardar automáticamente
+
+
+
+ Auto saving of the last measurement when starting a new measurement. Use SHIFT to temporarily invert the behaviour.
+ Guardar automáticamente la última medición al comenzar una nueva medición. Utilice MAYÚS para invertir el comportamiento temporalmente.
+
+
+
+ Additive Selection
+ Selección aditiva
+
+
+
+ If checked, new selection will be added to the measurement. If unchecked, CTRL must be pressed to add a selection to the current measurement otherwise a new measurement will be started
+ Si está marcado, las nuevas selecciones serán agregadas a la medición. De lo contrario, CTRL debe de ser presionado para agregar una selección a la medición actual, de otro modo, una nueva medición será iniciada.
+
+
+
+ Settings
+ Ajustes
+
+
+
+ Mode:
+ Modo:
+
+
+
+ Result:
+ Resultado:
+
+
+
+ Save
+ Guardar
+
+
+
+ Save the measurement in the active document.
+ Guardar la medición en el documento activo.
+
+
+
+ Close
+ Cerrar
+
+
+
+ Close the measurement task.
+ Cerrar la tarea de medición.
+
+
+
+ MeasureGui::DlgPrefsMeasureAppearanceImp
+
+
+ Appearance
+ Apariencia
+
+
+
+ Text color
+ Color de texto
+
+
+
+ Text size
+ Tamaño de texto
+
+
+
+ Default property values
+ Valores de propiedad por defecto
+
+
+
+ Line color
+ Color de línea
+
+
+
+ px
+ px
+
+
+
+ Background color
+ Color de fondo
+
+
+
+ MeasureGui::QuickMeasure
+
+
+ Total area: %1
+ Área total: %1
+
+
+
+
+ Nominal distance: %1
+ Distancia nominal: %1
+
+
+
+ Area: %1
+ Área: %1
+
+
+
+ Area: %1, Radius: %2
+ Área: %1, Radio: %2
+
+
+
+ Total length: %1
+ Longitud total: %1
+
+
+
+ Angle: %1, Total length: %2
+ Ángulo: %1, Longitud total: %2
+
+
+
+ Length: %1
+ Longitud: %1
+
+
+
+ Radius: %1
+ Radio: %1
+
+
+
+ Distance: %1
+ Distancia: %1
+
+
+
+ Minimum distance: %1
+ Distancia mínima: %1
+
+
+
+ QObject
+
+
+ Measure
+ Medición
+
+
+
+ StdCmdMeasure
+
+
+ &Measure
+ &Medición
+
+
+
+
+ Measure a feature
+ Medir una característica
+
+
+
diff --git a/src/Mod/Part/CMakeLists.txt b/src/Mod/Part/CMakeLists.txt
index 6e916a0380..fff413f307 100644
--- a/src/Mod/Part/CMakeLists.txt
+++ b/src/Mod/Part/CMakeLists.txt
@@ -1,3 +1,6 @@
+if(BUILD_TRACY_FRAME_PROFILER)
+ add_definitions(-DBUILD_TRACY_FRAME_PROFILER)
+endif()
add_subdirectory(App)
if(BUILD_GUI)
diff --git a/src/Mod/Part/Gui/CMakeLists.txt b/src/Mod/Part/Gui/CMakeLists.txt
index ae9d47f00d..a802d7911b 100644
--- a/src/Mod/Part/Gui/CMakeLists.txt
+++ b/src/Mod/Part/Gui/CMakeLists.txt
@@ -24,6 +24,10 @@ set(PartGui_LIBS
MatGui
)
+if(BUILD_TRACY_FRAME_PROFILER)
+ list(APPEND PartGui_LIBS TracyClient)
+endif()
+
if(MSVC)
include_directories(
${CMAKE_SOURCE_DIR}/src/3rdParty/OpenGL/api
diff --git a/src/Mod/Part/Gui/SoBrepFaceSet.cpp b/src/Mod/Part/Gui/SoBrepFaceSet.cpp
index fe93c46f7b..1cad8eeeae 100644
--- a/src/Mod/Part/Gui/SoBrepFaceSet.cpp
+++ b/src/Mod/Part/Gui/SoBrepFaceSet.cpp
@@ -68,6 +68,8 @@
# include
#endif
+#include
+
#include
#include
#include
@@ -493,6 +495,8 @@ void SoBrepFaceSet::renderColoredArray(SoMaterialBundle *const materials)
void SoBrepFaceSet::GLRender(SoGLRenderAction *action)
{
+ ZoneScoped;
+
//SoBase::staticDataLock();
static bool init = false;
if (!init) {
diff --git a/src/Mod/Sketcher/App/ExternalGeometryExtension.cpp b/src/Mod/Sketcher/App/ExternalGeometryExtension.cpp
index 44a0eaa9c7..ced70dde49 100644
--- a/src/Mod/Sketcher/App/ExternalGeometryExtension.cpp
+++ b/src/Mod/Sketcher/App/ExternalGeometryExtension.cpp
@@ -33,9 +33,6 @@ using namespace Sketcher;
//---------- Geometry Extension
-constexpr std::array
- ExternalGeometryExtension::flag2str;
-
TYPESYSTEM_SOURCE(Sketcher::ExternalGeometryExtension, Part::GeometryMigrationPersistenceExtension)
void ExternalGeometryExtension::copyAttributes(Part::GeometryExtension* cpy) const
diff --git a/src/Mod/Sketcher/App/SketchGeometryExtension.cpp b/src/Mod/Sketcher/App/SketchGeometryExtension.cpp
index fa27e85d7a..00929143b6 100644
--- a/src/Mod/Sketcher/App/SketchGeometryExtension.cpp
+++ b/src/Mod/Sketcher/App/SketchGeometryExtension.cpp
@@ -32,10 +32,6 @@
using namespace Sketcher;
//---------- Geometry Extension
-constexpr std::array
- SketchGeometryExtension::internaltype2str;
-constexpr std::array
- SketchGeometryExtension::geometrymode2str;
TYPESYSTEM_SOURCE(Sketcher::SketchGeometryExtension, Part::GeometryMigrationPersistenceExtension)
diff --git a/src/Mod/Sketcher/App/planegcs/Constraints.h b/src/Mod/Sketcher/App/planegcs/Constraints.h
index 04c2003d5e..52d8d0ac36 100644
--- a/src/Mod/Sketcher/App/planegcs/Constraints.h
+++ b/src/Mod/Sketcher/App/planegcs/Constraints.h
@@ -228,7 +228,7 @@ public:
private:
std::vector weights;
- double numpoints;
+ std::size_t numpoints;
};
// Weighted Linear Combination
diff --git a/src/Tools/updatets.py b/src/Tools/updatets.py
index 31de1936f6..312d467138 100755
--- a/src/Tools/updatets.py
+++ b/src/Tools/updatets.py
@@ -97,6 +97,11 @@ directories = [
"workingdir": "./src/Mod/Material/Gui",
"tsdir": "Resources/translations",
},
+ {
+ "tsname": "Measure",
+ "workingdir": "./src/Mod/Measure/",
+ "tsdir": "Gui/Resources/translations",
+ },
{
"tsname": "Mesh",
"workingdir": "./src/Mod/Mesh/",