Merge pull request #24675 from Connor9220/PathCommandAnnotations
CAM: Path command annotations
This commit is contained in:
@@ -25,7 +25,6 @@
|
||||
#include <iomanip>
|
||||
#include <boost/algorithm/string.hpp>
|
||||
|
||||
|
||||
#include <Base/Exception.h>
|
||||
#include <Base/Reader.h>
|
||||
#include <Base/Rotation.h>
|
||||
@@ -45,9 +44,11 @@ TYPESYSTEM_SOURCE(Path::Command, Base::Persistence)
|
||||
Command::Command(const char* name, const std::map<std::string, double>& parameters)
|
||||
: Name(name)
|
||||
, Parameters(parameters)
|
||||
, Annotations()
|
||||
{}
|
||||
|
||||
Command::Command()
|
||||
: Annotations()
|
||||
{}
|
||||
|
||||
Command::~Command()
|
||||
@@ -136,20 +137,54 @@ std::string Command::toGCode(int precision, bool padzero) const
|
||||
}
|
||||
str << '.' << std::setw(width) << std::right << digits;
|
||||
}
|
||||
|
||||
// Add annotations as a comment if they exist
|
||||
if (!Annotations.empty()) {
|
||||
str << " ; ";
|
||||
bool first = true;
|
||||
for (const auto& pair : Annotations) {
|
||||
if (!first) {
|
||||
str << " ";
|
||||
}
|
||||
first = false;
|
||||
str << pair.first << ":";
|
||||
if (std::holds_alternative<std::string>(pair.second)) {
|
||||
str << "'" << std::get<std::string>(pair.second) << "'";
|
||||
}
|
||||
else if (std::holds_alternative<double>(pair.second)) {
|
||||
std::ostringstream oss;
|
||||
oss << std::fixed << std::setprecision(6) << std::get<double>(pair.second);
|
||||
str << oss.str();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return str.str();
|
||||
}
|
||||
|
||||
void Command::setFromGCode(const std::string& str)
|
||||
{
|
||||
Parameters.clear();
|
||||
Annotations.clear();
|
||||
|
||||
// Check for annotation comment and split the string
|
||||
std::string gcode_part = str;
|
||||
std::string annotation_part;
|
||||
|
||||
auto comment_pos = str.find("; ");
|
||||
if (comment_pos != std::string::npos) {
|
||||
gcode_part = str.substr(0, comment_pos);
|
||||
annotation_part = str.substr(comment_pos + 1); // length of "; "
|
||||
}
|
||||
|
||||
std::string mode = "none";
|
||||
std::string key;
|
||||
std::string value;
|
||||
for (unsigned int i = 0; i < str.size(); i++) {
|
||||
if ((isdigit(str[i])) || (str[i] == '-') || (str[i] == '.')) {
|
||||
value += str[i];
|
||||
for (unsigned int i = 0; i < gcode_part.size(); i++) {
|
||||
if ((isdigit(gcode_part[i])) || (gcode_part[i] == '-') || (gcode_part[i] == '.')) {
|
||||
value += gcode_part[i];
|
||||
}
|
||||
else if (isalpha(str[i])) {
|
||||
else if (isalpha(gcode_part[i])) {
|
||||
if (mode == "command") {
|
||||
if (!key.empty() && !value.empty()) {
|
||||
std::string cmd = key + value;
|
||||
@@ -180,24 +215,30 @@ void Command::setFromGCode(const std::string& str)
|
||||
}
|
||||
}
|
||||
else if (mode == "comment") {
|
||||
value += str[i];
|
||||
value += gcode_part[i];
|
||||
}
|
||||
key = str[i];
|
||||
key = gcode_part[i];
|
||||
}
|
||||
else if (str[i] == '(') {
|
||||
else if (gcode_part[i] == '(') {
|
||||
mode = "comment";
|
||||
}
|
||||
else if (str[i] == ')') {
|
||||
else if (gcode_part[i] == ')') {
|
||||
key = "(";
|
||||
value += ")";
|
||||
}
|
||||
else {
|
||||
// add non-ascii characters only if this is a comment
|
||||
if (mode == "comment") {
|
||||
value += str[i];
|
||||
value += gcode_part[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse annotations if found
|
||||
if (!annotation_part.empty()) {
|
||||
setAnnotations(annotation_part);
|
||||
}
|
||||
|
||||
if (!key.empty() && !value.empty()) {
|
||||
if ((mode == "command") || (mode == "comment")) {
|
||||
std::string cmd = key + value;
|
||||
@@ -329,24 +370,167 @@ void Command::scaleBy(double factor)
|
||||
}
|
||||
}
|
||||
|
||||
// Reimplemented from base class
|
||||
void Command::setAnnotation(const std::string& key, const std::string& value)
|
||||
{
|
||||
Annotations[key] = value;
|
||||
}
|
||||
|
||||
void Command::setAnnotation(const std::string& key, double value)
|
||||
{
|
||||
Annotations[key] = value;
|
||||
}
|
||||
|
||||
std::string Command::getAnnotation(const std::string& key) const
|
||||
{
|
||||
auto it = Annotations.find(key);
|
||||
if (it != Annotations.end()) {
|
||||
if (std::holds_alternative<std::string>(it->second)) {
|
||||
return std::get<std::string>(it->second);
|
||||
}
|
||||
else if (std::holds_alternative<double>(it->second)) {
|
||||
return std::to_string(std::get<double>(it->second));
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string Command::getAnnotationString(const std::string& key) const
|
||||
{
|
||||
auto it = Annotations.find(key);
|
||||
if (it != Annotations.end() && std::holds_alternative<std::string>(it->second)) {
|
||||
return std::get<std::string>(it->second);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
double Command::getAnnotationDouble(const std::string& key, double fallback) const
|
||||
{
|
||||
auto it = Annotations.find(key);
|
||||
if (it != Annotations.end() && std::holds_alternative<double>(it->second)) {
|
||||
return std::get<double>(it->second);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
std::variant<std::string, double> Command::getAnnotationValue(const std::string& key) const
|
||||
{
|
||||
auto it = Annotations.find(key);
|
||||
if (it != Annotations.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return std::string("");
|
||||
}
|
||||
|
||||
bool Command::hasAnnotation(const std::string& key) const
|
||||
{
|
||||
return Annotations.find(key) != Annotations.end();
|
||||
}
|
||||
|
||||
Command& Command::setAnnotations(const std::string& annotationString)
|
||||
{
|
||||
std::istringstream iss(annotationString);
|
||||
std::string token;
|
||||
while (iss >> token) {
|
||||
auto pos = token.find(':');
|
||||
if (pos != std::string::npos) {
|
||||
std::string key = token.substr(0, pos);
|
||||
std::string value = token.substr(pos + 1);
|
||||
|
||||
// If value starts and ends with single quote, treat as string
|
||||
if (value.size() >= 2 && value.front() == '\'' && value.back() == '\'') {
|
||||
Annotations[key] = value.substr(1, value.size() - 2);
|
||||
}
|
||||
else {
|
||||
// Try to parse as double, if successful store as double, otherwise as string
|
||||
try {
|
||||
size_t processed = 0;
|
||||
double numValue = std::stod(value, &processed);
|
||||
if (processed == value.length()) {
|
||||
Annotations[key] = numValue;
|
||||
}
|
||||
else {
|
||||
Annotations[key] = value;
|
||||
}
|
||||
}
|
||||
catch (const std::exception&) {
|
||||
Annotations[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
unsigned int Command::getMemSize() const
|
||||
{
|
||||
return toGCode().size();
|
||||
}
|
||||
|
||||
void Command::Save(Writer& writer) const
|
||||
void Command::Save(Base::Writer& writer) const
|
||||
{
|
||||
// this will only get used if saved as XML (probably never)
|
||||
writer.Stream() << writer.ind() << "<Command "
|
||||
<< "gcode=\"" << toGCode() << "\" />";
|
||||
writer.Stream() << std::endl;
|
||||
writer.Stream() << writer.ind() << "<Command gcode=\"" << toGCode() << "\"";
|
||||
|
||||
if (!Annotations.empty()) {
|
||||
writer.Stream() << " annotations=\"";
|
||||
bool first = true;
|
||||
for (const auto& pair : Annotations) {
|
||||
if (!first) {
|
||||
writer.Stream() << " ";
|
||||
}
|
||||
first = false;
|
||||
writer.Stream() << pair.first << ":";
|
||||
if (std::holds_alternative<std::string>(pair.second)) {
|
||||
writer.Stream() << "'" << std::get<std::string>(pair.second) << "'";
|
||||
}
|
||||
else if (std::holds_alternative<double>(pair.second)) {
|
||||
writer.Stream() << std::fixed << std::setprecision(6)
|
||||
<< std::get<double>(pair.second);
|
||||
}
|
||||
}
|
||||
writer.Stream() << "\"";
|
||||
}
|
||||
|
||||
writer.Stream() << " />" << std::endl;
|
||||
}
|
||||
|
||||
void Command::Restore(XMLReader& reader)
|
||||
void Command::Restore(Base::XMLReader& reader)
|
||||
{
|
||||
reader.readElement("Command");
|
||||
std::string gcode = reader.getAttribute<const char*>("gcode");
|
||||
setFromGCode(gcode);
|
||||
|
||||
Annotations.clear();
|
||||
|
||||
std::string attr;
|
||||
try {
|
||||
attr = reader.getAttribute<const char*>("annotations");
|
||||
}
|
||||
catch (...) {
|
||||
return; // No annotations
|
||||
}
|
||||
|
||||
std::istringstream iss(attr);
|
||||
std::string token;
|
||||
while (iss >> token) {
|
||||
auto pos = token.find(':');
|
||||
if (pos != std::string::npos) {
|
||||
std::string key = token.substr(0, pos);
|
||||
std::string value = token.substr(pos + 1);
|
||||
|
||||
// If value starts and ends with single quote, treat as string
|
||||
if (value.size() >= 2 && value.front() == '\'' && value.back() == '\'') {
|
||||
Annotations[key] = value.substr(1, value.size() - 2);
|
||||
}
|
||||
else {
|
||||
try {
|
||||
double d = std::stod(value);
|
||||
Annotations[key] = d;
|
||||
}
|
||||
catch (...) {
|
||||
Annotations[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
#include <Base/Persistence.h>
|
||||
#include <Base/Placement.h>
|
||||
#include <Base/Vector3D.h>
|
||||
@@ -71,6 +72,22 @@ public:
|
||||
double getValue(const std::string& name) const; // returns the value of a given parameter
|
||||
void scaleBy(double factor); // scales the receiver - use for imperial/metric conversions
|
||||
|
||||
// annotation methods
|
||||
void setAnnotation(const std::string& key,
|
||||
const std::string& value); // sets a string annotation
|
||||
void setAnnotation(const std::string& key,
|
||||
double value); // sets a numeric annotation
|
||||
std::string getAnnotation(const std::string& key) const; // gets an annotation value as string
|
||||
std::string getAnnotationString(const std::string& key) const; // gets string annotation
|
||||
double getAnnotationDouble(const std::string& key,
|
||||
double fallback = 0.0) const; // gets numeric annotation
|
||||
std::variant<std::string, double>
|
||||
getAnnotationValue(const std::string& key) const; // gets raw annotation value
|
||||
bool hasAnnotation(const std::string& key) const; // checks if annotation exists
|
||||
Command&
|
||||
setAnnotations(const std::string& annotationString); // sets annotations from string and
|
||||
// returns reference for chaining
|
||||
|
||||
// this assumes the name is upper case
|
||||
inline double getParam(const std::string& name, double fallback = 0.0) const
|
||||
{
|
||||
@@ -81,6 +98,7 @@ public:
|
||||
// attributes
|
||||
std::string Name;
|
||||
std::map<std::string, double> Parameters;
|
||||
std::map<std::string, std::variant<std::string, double>> Annotations;
|
||||
};
|
||||
|
||||
} // namespace Path
|
||||
|
||||
@@ -31,11 +31,18 @@ class Command(Persistence):
|
||||
def transform(self, placement: Placement) -> "CommandPy":
|
||||
"""transform(Placement): returns a copy of this command transformed by the given placement"""
|
||||
...
|
||||
|
||||
def addAnnotations(self, annotations) -> "Command":
|
||||
"""addAnnotations(annotations): adds annotations from dictionary or string and returns self for chaining"""
|
||||
...
|
||||
Name: str
|
||||
"""The name of the command"""
|
||||
|
||||
Parameters: dict[str, float]
|
||||
"""The parameters of the command"""
|
||||
|
||||
Annotations: dict[str, str]
|
||||
"""The annotations of the command"""
|
||||
|
||||
Placement: Placement
|
||||
"""The coordinates of the endpoint of the command"""
|
||||
|
||||
@@ -203,6 +203,57 @@ void CommandPy::setParameters(Py::Dict arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Annotations attribute get/set
|
||||
|
||||
Py::Dict CommandPy::getAnnotations() const
|
||||
{
|
||||
Py::Dict annotationsDict;
|
||||
for (const auto& pair : getCommandPtr()->Annotations) {
|
||||
if (std::holds_alternative<std::string>(pair.second)) {
|
||||
annotationsDict.setItem(pair.first, Py::String(std::get<std::string>(pair.second)));
|
||||
}
|
||||
else if (std::holds_alternative<double>(pair.second)) {
|
||||
annotationsDict.setItem(pair.first, Py::Float(std::get<double>(pair.second)));
|
||||
}
|
||||
}
|
||||
return annotationsDict;
|
||||
}
|
||||
|
||||
void CommandPy::setAnnotations(Py::Dict arg)
|
||||
{
|
||||
getCommandPtr()->Annotations.clear();
|
||||
PyObject *key, *value;
|
||||
Py_ssize_t pos = 0;
|
||||
while (PyDict_Next(arg.ptr(), &pos, &key, &value)) {
|
||||
std::string ckey;
|
||||
if (PyUnicode_Check(key)) {
|
||||
ckey = PyUnicode_AsUTF8(key);
|
||||
|
||||
if (PyUnicode_Check(value)) {
|
||||
// String value
|
||||
std::string cvalue = PyUnicode_AsUTF8(value);
|
||||
getCommandPtr()->Annotations[ckey] = cvalue;
|
||||
}
|
||||
else if (PyFloat_Check(value)) {
|
||||
// Float value
|
||||
double dvalue = PyFloat_AsDouble(value);
|
||||
getCommandPtr()->Annotations[ckey] = dvalue;
|
||||
}
|
||||
else if (PyLong_Check(value)) {
|
||||
// Integer value (convert to double)
|
||||
double dvalue = static_cast<double>(PyLong_AsLong(value));
|
||||
getCommandPtr()->Annotations[ckey] = dvalue;
|
||||
}
|
||||
else {
|
||||
throw Py::TypeError("Annotation values must be strings or numbers");
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw Py::TypeError("Annotation keys must be strings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GCode methods
|
||||
|
||||
PyObject* CommandPy::toGCode(PyObject* args) const
|
||||
@@ -266,6 +317,47 @@ PyObject* CommandPy::transform(PyObject* args)
|
||||
}
|
||||
}
|
||||
|
||||
PyObject* CommandPy::addAnnotations(PyObject* args)
|
||||
{
|
||||
PyObject* annotationsObj;
|
||||
if (PyArg_ParseTuple(args, "O", &annotationsObj)) {
|
||||
if (PyDict_Check(annotationsObj)) {
|
||||
// Handle dictionary input
|
||||
PyObject *key, *value;
|
||||
Py_ssize_t pos = 0;
|
||||
while (PyDict_Next(annotationsObj, &pos, &key, &value)) {
|
||||
std::string ckey, cvalue;
|
||||
if (PyUnicode_Check(key) && PyUnicode_Check(value)) {
|
||||
ckey = PyUnicode_AsUTF8(key);
|
||||
cvalue = PyUnicode_AsUTF8(value);
|
||||
getCommandPtr()->setAnnotation(ckey, cvalue);
|
||||
}
|
||||
else {
|
||||
PyErr_SetString(PyExc_TypeError, "Dictionary keys and values must be strings");
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (PyUnicode_Check(annotationsObj)) {
|
||||
// Handle string input like "xyz:abc test:1234"
|
||||
std::string annotationString = PyUnicode_AsUTF8(annotationsObj);
|
||||
getCommandPtr()->setAnnotations(annotationString);
|
||||
}
|
||||
else {
|
||||
PyErr_SetString(PyExc_TypeError, "Argument must be a dictionary or string");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Return self for chaining
|
||||
Py_INCREF(this);
|
||||
return static_cast<PyObject*>(this);
|
||||
}
|
||||
else {
|
||||
PyErr_SetString(PyExc_TypeError, "Invalid arguments");
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// custom attributes get/set
|
||||
|
||||
PyObject* CommandPy::getCustomAttributes(const char* attr) const
|
||||
|
||||
@@ -523,13 +523,36 @@ void Toolpath::Restore(XMLReader& reader)
|
||||
}
|
||||
}
|
||||
|
||||
// The previous implementation read the file word-by-word, merging all content into a single string.
|
||||
// This caused GCode commands and annotations to be split and parsed incorrectly.
|
||||
// The new implementation reads the file line-by-line, ensuring each command and its annotations are
|
||||
// handled correctly. void Toolpath::RestoreDocFile(Base::Reader& reader)
|
||||
// {
|
||||
// std::string gcode;
|
||||
// std::string line;
|
||||
// while (reader >> line) {
|
||||
// gcode += line;
|
||||
// gcode += " ";
|
||||
// }
|
||||
// setFromGCode(gcode);
|
||||
// }
|
||||
|
||||
void Toolpath::addCommandNoRecalc(const Command& Cmd)
|
||||
{
|
||||
Command* tmp = new Command(Cmd);
|
||||
vpcCommands.push_back(tmp);
|
||||
// No recalculate here
|
||||
}
|
||||
|
||||
void Toolpath::RestoreDocFile(Base::Reader& reader)
|
||||
{
|
||||
std::string gcode;
|
||||
std::string line;
|
||||
while (reader >> line) {
|
||||
gcode += line;
|
||||
gcode += " ";
|
||||
while (std::getline(reader.getStream(), line)) {
|
||||
if (!line.empty()) {
|
||||
Command cmd;
|
||||
cmd.setFromGCode(line);
|
||||
addCommandNoRecalc(cmd);
|
||||
}
|
||||
}
|
||||
setFromGCode(gcode);
|
||||
recalculate(); // Only once, after all commands are loaded
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ public:
|
||||
// interface
|
||||
void clear(); // clears the internal data
|
||||
void addCommand(const Command& Cmd); // adds a command at the end
|
||||
void addCommandNoRecalc(const Command& Cmd); // adds a command without recalculation
|
||||
void insertCommand(const Command& Cmd, int); // inserts a command
|
||||
void deleteCommand(int); // deletes a command
|
||||
double getLength(); // return the Length (mm) of the Path
|
||||
|
||||
313
src/Mod/CAM/CAMTests/TestPathCommandAnnotations.py
Normal file
313
src/Mod/CAM/CAMTests/TestPathCommandAnnotations.py
Normal file
@@ -0,0 +1,313 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ***************************************************************************
|
||||
# * Copyright (c) 2025 FreeCAD Contributors *
|
||||
# * *
|
||||
# * This program is free software; you can redistribute it and/or modify *
|
||||
# * it under the terms of the GNU Lesser General Public License (LGPL) *
|
||||
# * as published by the Free Software Foundation; either version 2 of *
|
||||
# * the License, or (at your option) any later version. *
|
||||
# * for detail see the LICENCE text file. *
|
||||
# * *
|
||||
# * This program 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 Library General Public License for more details. *
|
||||
# * *
|
||||
# * You should have received a copy of the GNU Library General Public *
|
||||
# * License along with this program; if not, write to the Free Software *
|
||||
# * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 *
|
||||
# * USA *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
import FreeCAD
|
||||
import Path
|
||||
from CAMTests.PathTestUtils import PathTestBase
|
||||
|
||||
|
||||
class TestPathCommandAnnotations(PathTestBase):
|
||||
"""Test Path.Command annotations functionality."""
|
||||
|
||||
def test00(self):
|
||||
"""Test basic annotations property access."""
|
||||
# Create empty command
|
||||
c = Path.Command()
|
||||
self.assertIsInstance(c, Path.Command)
|
||||
|
||||
# Test empty annotations
|
||||
self.assertEqual(c.Annotations, {})
|
||||
|
||||
# Set annotations via property
|
||||
c.Annotations = {"tool": "tap", "material": "steel"}
|
||||
self.assertEqual(c.Annotations, {"tool": "tap", "material": "steel"})
|
||||
|
||||
# Test individual annotation access
|
||||
self.assertEqual(c.Annotations.get("tool"), "tap")
|
||||
self.assertEqual(c.Annotations.get("material"), "steel")
|
||||
self.assertIsNone(c.Annotations.get("nonexistent"))
|
||||
|
||||
def test01(self):
|
||||
"""Test annotations with command creation."""
|
||||
# Create command with parameters
|
||||
c = Path.Command("G84", {"X": 10, "Y": 20, "Z": -5})
|
||||
|
||||
# Set annotations
|
||||
c.Annotations = {"operation": "tapping", "thread": "M6"}
|
||||
|
||||
# Verify command still works normally
|
||||
self.assertEqual(c.Name, "G84")
|
||||
self.assertEqual(c.Parameters["X"], 10.0)
|
||||
self.assertEqual(c.Parameters["Y"], 20.0)
|
||||
self.assertEqual(c.Parameters["Z"], -5.0)
|
||||
|
||||
# Verify annotations are preserved
|
||||
self.assertEqual(c.Annotations["operation"], "tapping")
|
||||
self.assertEqual(c.Annotations["thread"], "M6")
|
||||
|
||||
def test02(self):
|
||||
"""Test addAnnotations method with dictionary input."""
|
||||
c = Path.Command("G1", {"X": 5, "Y": 5})
|
||||
|
||||
# Test method chaining with dictionary
|
||||
result = c.addAnnotations({"note": "test note", "tool": "end mill"})
|
||||
|
||||
# Verify method returns the command object for chaining
|
||||
self.assertIs(result, c)
|
||||
|
||||
# Verify annotations were set
|
||||
self.assertEqual(c.Annotations["note"], "test note")
|
||||
self.assertEqual(c.Annotations["tool"], "end mill")
|
||||
|
||||
def test03(self):
|
||||
"""Test addAnnotations method with string input."""
|
||||
c = Path.Command("G2", {"X": 15, "Y": 15})
|
||||
|
||||
# Test method chaining with string
|
||||
result = c.addAnnotations("xyz:abc test:1234 operation:milling")
|
||||
|
||||
# Verify method returns the command object for chaining
|
||||
self.assertIs(result, c)
|
||||
|
||||
# Verify annotations were parsed and set correctly
|
||||
self.assertEqual(c.Annotations["xyz"], "abc")
|
||||
self.assertEqual(c.Annotations["test"], 1234)
|
||||
self.assertEqual(c.Annotations["operation"], "milling")
|
||||
|
||||
def test04(self):
|
||||
"""Test annotations update behavior."""
|
||||
c = Path.Command("G0", {"Z": 20})
|
||||
|
||||
# Set initial annotations
|
||||
c.Annotations = {"initial": "value"}
|
||||
self.assertEqual(c.Annotations, {"initial": "value"})
|
||||
|
||||
# Add more annotations - should merge/update
|
||||
c.addAnnotations({"additional": "value2", "initial": "updated"})
|
||||
|
||||
expected = {"initial": "updated", "additional": "value2"}
|
||||
self.assertEqual(c.Annotations, expected)
|
||||
|
||||
def test05(self):
|
||||
"""Test method chaining in fluent interface."""
|
||||
# Test the fluent interface - create command and set annotations in one line
|
||||
c = Path.Command("G84", {"X": 10, "Y": 10, "Z": 0.0}).addAnnotations("thread:M8 depth:15mm")
|
||||
|
||||
# Verify command parameters
|
||||
self.assertEqual(c.Name, "G84")
|
||||
self.assertEqual(c.Parameters["X"], 10.0)
|
||||
self.assertEqual(c.Parameters["Y"], 10.0)
|
||||
self.assertEqual(c.Parameters["Z"], 0.0)
|
||||
|
||||
# Verify annotations
|
||||
self.assertEqual(c.Annotations["thread"], "M8")
|
||||
self.assertEqual(c.Annotations["depth"], "15mm")
|
||||
|
||||
def test06(self):
|
||||
"""Test annotations with special characters and edge cases."""
|
||||
c = Path.Command("G1")
|
||||
|
||||
# Test annotations with special characters
|
||||
c.Annotations = {
|
||||
"unicode": "café",
|
||||
"numbers": "123.45",
|
||||
"empty": "",
|
||||
"spaces": "value with spaces",
|
||||
}
|
||||
|
||||
self.assertEqual(c.Annotations["unicode"], "café")
|
||||
self.assertEqual(c.Annotations["numbers"], "123.45")
|
||||
self.assertEqual(c.Annotations["empty"], "")
|
||||
self.assertEqual(c.Annotations["spaces"], "value with spaces")
|
||||
|
||||
def test07(self):
|
||||
"""Test annotations persistence through operations."""
|
||||
c = Path.Command("G1", {"X": 10, "Y": 20})
|
||||
c.Annotations = {"persistent": "value"}
|
||||
|
||||
# Test that annotations survive parameter changes
|
||||
c.Parameters = {"X": 30, "Y": 40}
|
||||
self.assertEqual(c.Annotations["persistent"], "value")
|
||||
|
||||
# Test that annotations survive name changes
|
||||
c.Name = "G2"
|
||||
self.assertEqual(c.Annotations["persistent"], "value")
|
||||
|
||||
def test08(self):
|
||||
"""Test multiple annotation update methods."""
|
||||
c = Path.Command()
|
||||
|
||||
# Method 1: Property assignment
|
||||
c.Annotations = {"method1": "property"}
|
||||
|
||||
# Method 2: addAnnotations with dict
|
||||
c.addAnnotations({"method2": "dict"})
|
||||
|
||||
# Method 3: addAnnotations with string
|
||||
c.addAnnotations("method3:string")
|
||||
|
||||
# Verify all methods worked and annotations are merged
|
||||
expected = {"method1": "property", "method2": "dict", "method3": "string"}
|
||||
self.assertEqual(c.Annotations, expected)
|
||||
|
||||
def test09(self):
|
||||
"""Test string parsing edge cases."""
|
||||
c = Path.Command()
|
||||
|
||||
# Test various string formats
|
||||
c.addAnnotations("simple:value")
|
||||
self.assertEqual(c.Annotations["simple"], "value")
|
||||
|
||||
# Test multiple key:value pairs
|
||||
c.Annotations = {} # Clear first
|
||||
c.addAnnotations("key1:val1 key2:val2 key3:val3")
|
||||
expected = {"key1": "val1", "key2": "val2", "key3": "val3"}
|
||||
self.assertEqual(c.Annotations, expected)
|
||||
|
||||
# Test that malformed strings are ignored
|
||||
c.Annotations = {} # Clear first
|
||||
c.addAnnotations("valid:value invalid_no_colon")
|
||||
self.assertEqual(c.Annotations, {"valid": "value"})
|
||||
|
||||
def test10(self):
|
||||
"""Test annotations in gcode context."""
|
||||
# Create a tapping command with annotations
|
||||
c = Path.Command(
|
||||
"G84", {"X": 25.0, "Y": 30.0, "Z": -10.0, "R": 2.0, "P": 0.5, "F": 100.0}
|
||||
).addAnnotations("operation:tapping thread:M6x1.0 depth:10mm")
|
||||
|
||||
# Verify gcode output is unaffected by annotations
|
||||
gcode = c.toGCode()
|
||||
self.assertIn("G84", gcode)
|
||||
self.assertIn("X25", gcode)
|
||||
self.assertIn("Y30", gcode)
|
||||
self.assertIn("Z-10", gcode)
|
||||
|
||||
# Verify annotations are preserved
|
||||
self.assertEqual(c.Annotations["operation"], "tapping")
|
||||
self.assertEqual(c.Annotations["thread"], "M6x1.0")
|
||||
self.assertEqual(c.Annotations["depth"], "10mm")
|
||||
|
||||
# Annotations should not appear in gcode output
|
||||
gcode_parts = gcode.split(";", 1)
|
||||
main_gcode = gcode_parts[0]
|
||||
comment = gcode_parts[1] if len(gcode_parts) > 1 else ""
|
||||
|
||||
self.assertIn("operation:'tapping'", comment)
|
||||
self.assertIn("thread:'M6x1.0'", comment)
|
||||
self.assertIn("depth:'10mm'", comment)
|
||||
self.assertNotIn("operation", main_gcode)
|
||||
self.assertNotIn("thread", main_gcode)
|
||||
self.assertNotIn("depth", main_gcode)
|
||||
|
||||
def test11(self):
|
||||
"""Test save/restore with mixed string and numeric annotations (in-memory)."""
|
||||
# Create command with mixed annotations
|
||||
original = Path.Command("G1", {"X": 10.0, "Y": 20.0, "F": 1000.0})
|
||||
original.Annotations = {
|
||||
"tool_name": "6mm_endmill", # string
|
||||
"spindle_speed": 12000.0, # float
|
||||
"feed_rate": 1500, # int -> float
|
||||
"operation": "pocket", # string
|
||||
"depth_of_cut": -2.5, # negative float
|
||||
}
|
||||
|
||||
# Use FreeCAD's in-memory serialization
|
||||
content = original.dumpContent()
|
||||
|
||||
# Create new command and restore from memory
|
||||
restored = Path.Command()
|
||||
restored.restoreContent(content)
|
||||
|
||||
# Verify all annotations were restored with correct types
|
||||
self.assertEqual(restored.Annotations["tool_name"], "6mm_endmill")
|
||||
self.assertEqual(restored.Annotations["spindle_speed"], 12000.0)
|
||||
self.assertEqual(restored.Annotations["feed_rate"], 1500.0)
|
||||
self.assertEqual(restored.Annotations["operation"], "pocket")
|
||||
self.assertEqual(restored.Annotations["depth_of_cut"], -2.5)
|
||||
|
||||
# Verify types are preserved
|
||||
self.assertIsInstance(restored.Annotations["tool_name"], str)
|
||||
self.assertIsInstance(restored.Annotations["spindle_speed"], float)
|
||||
self.assertIsInstance(restored.Annotations["feed_rate"], float)
|
||||
self.assertIsInstance(restored.Annotations["operation"], str)
|
||||
self.assertIsInstance(restored.Annotations["depth_of_cut"], float)
|
||||
|
||||
# Verify GCode parameters were also restored correctly
|
||||
self.assertEqual(restored.Name, "G1")
|
||||
# Note: Parameters are restored via GCode parsing
|
||||
|
||||
def test12(self):
|
||||
"""Test save/restore with empty and complex annotations (in-memory)."""
|
||||
# Test 1: Empty annotations (should work and use compact format)
|
||||
simple = Path.Command("G0", {"Z": 5.0})
|
||||
self.assertEqual(simple.Annotations, {})
|
||||
|
||||
simple_content = simple.dumpContent()
|
||||
simple_restored = Path.Command()
|
||||
simple_restored.restoreContent(simple_content)
|
||||
|
||||
self.assertEqual(simple_restored.Annotations, {})
|
||||
self.assertEqual(simple_restored.Name, "G0")
|
||||
|
||||
# Test 2: Complex CAM annotations with edge cases
|
||||
complex_cmd = Path.Command("G84", {"X": 25.4, "Y": 12.7, "Z": -8.0})
|
||||
complex_cmd.Annotations = {
|
||||
# Mixed types with edge cases
|
||||
"tool_type": "tap", # string
|
||||
"spindle_speed": 500.0, # float
|
||||
"zero_value": 0.0, # zero
|
||||
"negative": -123.456, # negative
|
||||
"large_number": 999999.999, # large number
|
||||
"operation_id": "OP_030", # alphanumeric string
|
||||
"thread_spec": "M4x0.7", # string with numbers
|
||||
"scientific": 1.23e-6, # scientific notation
|
||||
}
|
||||
|
||||
# Serialize and restore
|
||||
complex_content = complex_cmd.dumpContent()
|
||||
complex_restored = Path.Command()
|
||||
complex_restored.restoreContent(complex_content)
|
||||
|
||||
# Verify all complex data restored correctly
|
||||
self.assertEqual(len(complex_restored.Annotations), 8)
|
||||
|
||||
# Check specific values and types
|
||||
self.assertEqual(complex_restored.Annotations["tool_type"], "tap")
|
||||
self.assertIsInstance(complex_restored.Annotations["tool_type"], str)
|
||||
|
||||
self.assertEqual(complex_restored.Annotations["spindle_speed"], 500.0)
|
||||
self.assertIsInstance(complex_restored.Annotations["spindle_speed"], float)
|
||||
|
||||
self.assertEqual(complex_restored.Annotations["zero_value"], 0.0)
|
||||
self.assertEqual(complex_restored.Annotations["negative"], -123.456)
|
||||
self.assertEqual(complex_restored.Annotations["large_number"], 999999.999)
|
||||
|
||||
# Verify strings with numbers stay as strings
|
||||
self.assertEqual(complex_restored.Annotations["operation_id"], "OP_030")
|
||||
self.assertEqual(complex_restored.Annotations["thread_spec"], "M4x0.7")
|
||||
self.assertIsInstance(complex_restored.Annotations["operation_id"], str)
|
||||
self.assertIsInstance(complex_restored.Annotations["thread_spec"], str)
|
||||
|
||||
# Check scientific notation (now only 6 decimal places)
|
||||
self.assertAlmostEqual(complex_restored.Annotations["scientific"], 1.23e-6, places=6)
|
||||
self.assertIsInstance(complex_restored.Annotations["scientific"], float)
|
||||
@@ -499,6 +499,7 @@ SET(Tests_SRCS
|
||||
CAMTests/TestLinuxCNCPost.py
|
||||
CAMTests/TestMach3Mach4Post.py
|
||||
CAMTests/TestPathAdaptive.py
|
||||
CAMTests/TestPathCommandAnnotations.py
|
||||
CAMTests/TestPathCore.py
|
||||
CAMTests/TestPathDepthParams.py
|
||||
CAMTests/TestPathDressupArray.py
|
||||
|
||||
@@ -140,6 +140,14 @@ const char* GCodeParser::ParseFloat(const char* ptr, float* retFloat)
|
||||
|
||||
bool GCodeParser::ParseLine(const char* ptr)
|
||||
{
|
||||
// Truncate at first semicolon (annotations / comment)
|
||||
const char* comment = strchr(ptr, ';');
|
||||
std::string line;
|
||||
if (comment) {
|
||||
line = std::string(ptr, comment - ptr);
|
||||
ptr = line.c_str();
|
||||
}
|
||||
|
||||
GCToken token;
|
||||
bool validMotion = false;
|
||||
bool exitLoop = false;
|
||||
|
||||
@@ -28,6 +28,7 @@ from CAMTests.TestCAMSanity import TestCAMSanity
|
||||
from CAMTests.TestPathProfile import TestPathProfile
|
||||
|
||||
from CAMTests.TestPathAdaptive import TestPathAdaptive
|
||||
from CAMTests.TestPathCommandAnnotations import TestPathCommandAnnotations
|
||||
from CAMTests.TestPathCore import TestPathCore
|
||||
from CAMTests.TestPathDepthParams import depthTestCases
|
||||
from CAMTests.TestPathDressupDogbone import TestDressupDogbone
|
||||
|
||||
Reference in New Issue
Block a user