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/",