Sketcher: add reverse mapping correction to Carbon Copy (#25745)

* Sketcher: add reverse mapping correction to Carbon Copy

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Replace M_PI with std::numbers::pi

* Replace vector initialization with Base::Vector3d::UnitX/UnitY, apply suggestions from code review

Co-authored-by: Kacper Donat <kadet1090@gmail.com>

* Fix guard clause logic, apply suggestions from code review

* Replace std::numbers::pi with pi via `using std::numbers`

* Fix issues reported by the linter

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Kacper Donat <kadet1090@gmail.com>
This commit is contained in:
marbocub
2025-12-22 23:00:49 +09:00
committed by GitHub
parent 46066e1cca
commit 176ef6da4e
7 changed files with 368 additions and 6 deletions

View File

@@ -7064,6 +7064,8 @@ bool SketchObject::insertBSplineKnot(int GeoId, double param, int multiplicity)
int SketchObject::carbonCopy(App::DocumentObject* pObj, bool construction)
{
using std::numbers::pi;
// no need to check input data validity as this is an sketchobject managed operation.
Base::StateLocker lock(managedoperation, true);
@@ -7096,6 +7098,11 @@ int SketchObject::carbonCopy(App::DocumentObject* pObj, bool construction)
newVals.reserve(vals.size() + svals.size());
newcVals.reserve(cvals.size() + scvals.size());
const Base::Vector3d& origin = this->Placement.getValue().getPosition();
const Base::Rotation& rotation = this->Placement.getValue().getRotation();
const Base::Vector3d axisH = rotation.multVec(Base::Vector3d::UnitX);
const Base::Vector3d axisV = rotation.multVec(Base::Vector3d::UnitY);
std::map<int, int> extMap;
if (psObj->ExternalGeo.getSize() > 1) {
int i = -1;
@@ -7185,8 +7192,26 @@ int SketchObject::carbonCopy(App::DocumentObject* pObj, bool construction)
solverNeedsUpdate = true;
}
auto applyGeometryFlipCorrection = [xinv, yinv, origin, axisV, axisH]
(Part::Geometry* geoNew) {
if (!xinv && !yinv) {
return;
}
if (xinv) {
geoNew->mirror(origin, axisV);
}
if (yinv) {
geoNew->mirror(origin, axisH);
}
};
for (std::vector<Part::Geometry*>::const_iterator it = svals.begin(); it != svals.end(); ++it) {
Part::Geometry* geoNew = (*it)->copy();
if (xinv || yinv) {
// corrections for flipped geometry
applyGeometryFlipCorrection(geoNew);
}
generateId(geoNew);
if (construction && !geoNew->is<Part::GeomPoint>()) {
GeometryFacade::setConstruction(geoNew, true);
@@ -7194,6 +7219,65 @@ int SketchObject::carbonCopy(App::DocumentObject* pObj, bool construction)
newVals.push_back(geoNew);
}
auto applyConstraintFlipCorrection = [xinv, yinv]
(Sketcher::Constraint* newConstr) {
if (!xinv && !yinv) {
return;
}
// DistanceX, DistanceY
if ((xinv && newConstr->Type == Sketcher::DistanceX) ||
(yinv && newConstr->Type == Sketcher::DistanceY)) {
if (newConstr->First == newConstr->Second) {
std::swap(newConstr->FirstPos, newConstr->SecondPos);
} else{
newConstr->setValue(-newConstr->getValue());
}
}
// Angle
if (newConstr->Type == Sketcher::Angle) {
auto normalizeAngle = [](double angleDeg) {
while (angleDeg > pi) angleDeg -= pi * 2.0;
while (angleDeg <= -pi) angleDeg += pi * 2.0;
return angleDeg;
};
if (xinv && yinv) { // rotation 180 degrees around normal axis
if (newConstr->First ==-1 || newConstr->Second == -1
|| newConstr->First == -2 || newConstr->Second == -2
|| newConstr->Second == GeoEnum::GeoUndef) {
// angle to horizontal or vertical axis
newConstr->setValue(normalizeAngle(newConstr->getValue() + pi));
}
else {
// angle between two sketch entities
// do nothing
}
}
else if (xinv) { // rotation 180 degrees around vertical axis
if (newConstr->First == -1 || newConstr->Second == -1 || newConstr->Second == GeoEnum::GeoUndef) {
// angle to horizontal axis
newConstr->setValue(normalizeAngle(pi - newConstr->getValue()));
}
else {
// angle between two sketch entities or angle to vertical axis
newConstr->setValue(normalizeAngle(-newConstr->getValue()));
}
}
else if (yinv) { // rotation 180 degrees around horizontal axis
if (newConstr->First == -2 || newConstr->Second == -2) {
// angle to vertical axis
newConstr->setValue(normalizeAngle(pi - newConstr->getValue()));
}
else {
// angle between two sketch entities or angle to horizontal axis
newConstr->setValue(normalizeAngle(-newConstr->getValue()));
}
}
}
};
for (std::vector<Sketcher::Constraint*>::const_iterator it = scvals.begin(); it != scvals.end();
++it) {
Sketcher::Constraint* newConstr = (*it)->copy();
@@ -7211,6 +7295,11 @@ int SketchObject::carbonCopy(App::DocumentObject* pObj, bool construction)
if ((*it)->Third < -2 && (*it)->Third != GeoEnum::GeoUndef)
newConstr->Third -= (nextextgeoid - 2);
if (xinv || yinv) {
// corrections for flipped constraints
applyConstraintFlipCorrection(newConstr);
}
newcVals.push_back(newConstr);
}
@@ -7225,6 +7314,62 @@ int SketchObject::carbonCopy(App::DocumentObject* pObj, bool construction)
// ViewProvider::UpdateData is triggered.
Geometry.touch();
auto makeCorrectedExpressionString = [xinv, yinv]
(const Sketcher::Constraint* constr, const std::string expr)
-> std::string {
if (!xinv && !yinv) {
return expr;
}
// DistanceX, DistanceY
if ((xinv && constr->Type == Sketcher::DistanceX) ||
(yinv && constr->Type == Sketcher::DistanceY)) {
if (constr->First == constr->Second) {
return expr;
} else{
return "-(" + expr + ")";
}
}
// Angle
if (constr->Type == Sketcher::Angle) {
if (xinv && yinv) { // rotation 180 degrees around normal axis
if (constr->First ==-1 || constr->Second == -1
|| constr->First == -2 || constr->Second == -2
|| constr->Second == GeoEnum::GeoUndef) {
// angle to horizontal or vertical axis
return "(" + expr + ") + 180 deg";
}
else {
// angle between two sketch entities
// do nothing
return expr;
}
}
else if (xinv) { // rotation 180 degrees around vertical axis
if (constr->First == -1 || constr->Second == -1 || constr->Second == GeoEnum::GeoUndef) {
// angle to horizontal axis
return "180 deg - (" + expr + ")";
}
else {
// angle between two sketch entities or angle to vertical axis
return "-(" + expr + ")";
}
}
else if (yinv) { // rotation 180 degrees around horizontal axis
if (constr->First == -2 || constr->Second == -2) {
// angle to vertical axis
return "180 deg - (" + expr + ")";
}
else {
// angle between two sketch entities or angle to horizontal axis
return "-(" + expr + ")";
}
}
}
return expr;
};
int sourceid = 0;
for (std::vector<Sketcher::Constraint*>::const_iterator it = scvals.begin(); it != scvals.end();
++it, nextcid++, sourceid++) {
@@ -7235,19 +7380,22 @@ int SketchObject::carbonCopy(App::DocumentObject* pObj, bool construction)
App::ObjectIdentifier spath;
std::shared_ptr<App::Expression> expr;
std::string scname = (*it)->Name;
std::string sref;
if (App::ExpressionParser::isTokenAnIndentifier(scname)) {
spath = App::ObjectIdentifier(psObj->Constraints)
<< App::ObjectIdentifier::SimpleComponent(scname);
expr = std::shared_ptr<App::Expression>(App::Expression::parse(
this, spath.getDocumentObjectName().getString() + spath.toString()));
sref = spath.getDocumentObjectName().getString() + spath.toString();
}
else {
spath = psObj->Constraints.createPath(sourceid);
expr = std::shared_ptr<App::Expression>(
App::Expression::parse(this,
spath.getDocumentObjectName().getString()
+ std::string(1, '.') + spath.toString()));
sref = spath.getDocumentObjectName().getString()
+ std::string(1, '.') + spath.toString();
}
if (xinv || yinv) {
// corrections for flipped expressions
sref = makeCorrectedExpressionString((*it), sref);
}
expr = std::shared_ptr<App::Expression>(App::Expression::parse(this, sref));
setExpression(Constraints.createPath(nextcid), std::move(expr));
}
}

View File

@@ -301,6 +301,18 @@ class SketchObject(Part2DObject):
"""
...
def setAllowUnaligned(self, state: bool, /) -> None:
"""
Set whether unaligned geometry is allowed in the sketch.
setAllowUnaligned(state:bool)
Args:
state: `True` allows unaligned geometry,
`False` enforces aligned geometry.
"""
...
def carbonCopy(self, objName: str, asConstruction: bool = True, /) -> None:
"""
Copy another sketch's geometry and constraints into this sketch.

View File

@@ -566,6 +566,18 @@ PyObject* SketchObjectPy::getIndexByName(PyObject* args) const
return nullptr;
}
PyObject* SketchObjectPy::setAllowUnaligned(PyObject* args)
{
PyObject* allowObj;
if (!PyArg_ParseTuple(args, "O!", &PyBool_Type, &allowObj)) {
return nullptr;
}
bool allow = Base::asBoolean(allowObj);
this->getSketchObjectPtr()->setAllowUnaligned(allow);
Py_Return;
}
PyObject* SketchObjectPy::carbonCopy(PyObject* args)
{
char* ObjectName;

View File

@@ -17,6 +17,8 @@ set(Sketcher_TestScripts
SketcherTests/TestSketcherSolver.py
SketcherTests/TestSketchExpression.py
SketcherTests/TestSketchValidateCoincidents.py
SketcherTests/TestSketchCarbonCopyReverseMapping.py
SketcherTests/TestSketchCarbonCopyReverseMapping.FCStd
)
if(BUILD_GUI)

View File

@@ -0,0 +1,187 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
import FreeCAD
import math, os, unittest
class TestSketchCarbonCopyReverseMapping(unittest.TestCase):
def setUp(self):
location = os.path.dirname(os.path.realpath(__file__))
self.Doc = FreeCAD.openDocument(
os.path.join(location, "TestSketchCarbonCopyReverseMapping.FCStd"), True
)
def test_CarbonCopyReverseMapping(self):
r = 10.0
rad30 = math.radians(30)
cos30 = math.cos(rad30)
sin30 = math.sin(rad30)
expected_geometries_pattern_001 = {
"Sketch001": {
0: {"start": FreeCAD.Vector(10, 10, 0), "end": FreeCAD.Vector(20, 10, 0)},
1: {
"start": FreeCAD.Vector(10, 10, 0),
"end": FreeCAD.Vector(10 + cos30 * r, 10 + sin30 * r, 0),
},
2: {
"start": FreeCAD.Vector(10, 10, 0),
"end": FreeCAD.Vector(10 + sin30 * r, 10 + cos30 * r, 0),
},
3: {
"start": FreeCAD.Vector(10, 10, 0),
"end": FreeCAD.Vector(10 - sin30 * r, 10 + cos30 * r, 0),
},
4: {"location": FreeCAD.Vector(-10, 10, 0)},
5: {"location": FreeCAD.Vector(10, -10, 0)},
6: {
"start": FreeCAD.Vector(5, 15, 0),
"end": FreeCAD.Vector(5 + sin30 * r, 15 + cos30 * r, 0),
},
7: {
"start": FreeCAD.Vector(15, 15, 0),
"end": FreeCAD.Vector(15 - sin30 * r, 15 + cos30 * r, 0),
},
},
"Sketch002": {
0: {"start": FreeCAD.Vector(-10, 10, 0), "end": FreeCAD.Vector(-20, 10, 0)},
1: {
"start": FreeCAD.Vector(-10, 10, 0),
"end": FreeCAD.Vector(-10 - cos30 * r, 10 + sin30 * r, 0),
},
2: {
"start": FreeCAD.Vector(-10, 10, 0),
"end": FreeCAD.Vector(-10 - sin30 * r, 10 + cos30 * r, 0),
},
3: {
"start": FreeCAD.Vector(-10, 10, 0),
"end": FreeCAD.Vector(-10 + sin30 * r, 10 + cos30 * r, 0),
},
4: {"location": FreeCAD.Vector(10, 10, 0)},
5: {"location": FreeCAD.Vector(-10, -10, 0)},
6: {
"start": FreeCAD.Vector(-5, 15, 0),
"end": FreeCAD.Vector(-5 - sin30 * r, 15 + cos30 * r, 0),
},
7: {
"start": FreeCAD.Vector(-15, 15, 0),
"end": FreeCAD.Vector(-15 + sin30 * r, 15 + cos30 * r, 0),
},
},
"Sketch003": {
0: {"start": FreeCAD.Vector(10, -10, 0), "end": FreeCAD.Vector(20, -10, 0)},
1: {
"start": FreeCAD.Vector(10, -10, 0),
"end": FreeCAD.Vector(10 + cos30 * r, -10 - sin30 * r, 0),
},
2: {
"start": FreeCAD.Vector(10, -10, 0),
"end": FreeCAD.Vector(10 + sin30 * r, -10 - cos30 * r, 0),
},
3: {
"start": FreeCAD.Vector(10, -10, 0),
"end": FreeCAD.Vector(10 - sin30 * r, -10 - cos30 * r, 0),
},
4: {"location": FreeCAD.Vector(-10, -10, 0)},
5: {"location": FreeCAD.Vector(10, 10, 0)},
6: {
"start": FreeCAD.Vector(5, -15, 0),
"end": FreeCAD.Vector(5 + sin30 * r, -15 - cos30 * r, 0),
},
7: {
"start": FreeCAD.Vector(15, -15, 0),
"end": FreeCAD.Vector(15 - sin30 * r, -15 - cos30 * r, 0),
},
},
"Sketch004": {
0: {"start": FreeCAD.Vector(-10, -10, 0), "end": FreeCAD.Vector(-20, -10, 0)},
1: {
"start": FreeCAD.Vector(-10, -10, 0),
"end": FreeCAD.Vector(-10 - cos30 * r, -10 - sin30 * r, 0),
},
2: {
"start": FreeCAD.Vector(-10, -10, 0),
"end": FreeCAD.Vector(-10 - sin30 * r, -10 - cos30 * r, 0),
},
3: {
"start": FreeCAD.Vector(-10, -10, 0),
"end": FreeCAD.Vector(-10 + sin30 * r, -10 - cos30 * r, 0),
},
4: {"location": FreeCAD.Vector(10, -10, 0)},
5: {"location": FreeCAD.Vector(-10, 10, 0)},
6: {
"start": FreeCAD.Vector(-5, -15, 0),
"end": FreeCAD.Vector(-5 - sin30 * r, -15 - cos30 * r, 0),
},
7: {
"start": FreeCAD.Vector(-15, -15, 0),
"end": FreeCAD.Vector(-15 + sin30 * r, -15 - cos30 * r, 0),
},
},
"Sketch005": {
0: {"start": FreeCAD.Vector(10, 10, 0), "end": FreeCAD.Vector(20, 10, 0)},
1: {
"start": FreeCAD.Vector(10, 10, 0),
"end": FreeCAD.Vector(10 + cos30 * r, 10 + sin30 * r, 0),
},
2: {
"start": FreeCAD.Vector(10, 10, 0),
"end": FreeCAD.Vector(10 + sin30 * r, 10 + cos30 * r, 0),
},
3: {
"start": FreeCAD.Vector(10, 10, 0),
"end": FreeCAD.Vector(10 - sin30 * r, 10 + cos30 * r, 0),
},
4: {"location": FreeCAD.Vector(-10, 10, 0)},
5: {"location": FreeCAD.Vector(10, -10, 0)},
6: {
"start": FreeCAD.Vector(5, 15, 0),
"end": FreeCAD.Vector(5 + sin30 * r, 15 + cos30 * r, 0),
},
7: {
"start": FreeCAD.Vector(15, 15, 0),
"end": FreeCAD.Vector(15 - sin30 * r, 15 + cos30 * r, 0),
},
},
}
expected_geometries_pattern_006 = {
"Sketch007": {
0: {"start": FreeCAD.Vector(-35, -15, 0), "end": FreeCAD.Vector(-35, -5, 0)},
1: {"start": FreeCAD.Vector(-35, -5, 0), "end": FreeCAD.Vector(-20, -5, 0)},
2: {"start": FreeCAD.Vector(-20, -5, 0), "end": FreeCAD.Vector(-20, -15, 0)},
3: {"start": FreeCAD.Vector(-20, -15, 0), "end": FreeCAD.Vector(-35, -15, 0)},
}
}
def execute_carbon_copy(src, dist):
obj = FreeCAD.ActiveDocument.getObject(dist)
obj.setAllowUnaligned(False)
obj.carbonCopy(src, False)
FreeCAD.ActiveDocument.recompute()
def check_placement(expected_dict):
for sketch, correct_points in expected_dict.items():
obj = FreeCAD.ActiveDocument.getObject(sketch)
for i, pts in correct_points.items():
geom = obj.Geometry[i]
if "start" in pts:
start = geom.StartPoint
end = geom.EndPoint
self.assertAlmostEqual((start - pts["start"]).Length, 0)
self.assertAlmostEqual((end - pts["end"]).Length, 0)
elif "location" in pts:
loc = geom.Location
self.assertAlmostEqual((loc - pts["location"]).Length, 0)
execute_carbon_copy("Sketch001", "Sketch002")
execute_carbon_copy("Sketch001", "Sketch003")
execute_carbon_copy("Sketch001", "Sketch004")
execute_carbon_copy("Sketch001", "Sketch005")
execute_carbon_copy("Sketch006", "Sketch007")
check_placement(expected_geometries_pattern_001)
check_placement(expected_geometries_pattern_006)
def tearDown(self):
FreeCAD.closeDocument(self.Doc.Name)

View File

@@ -26,6 +26,7 @@ from SketcherTests.TestSketcherSolver import TestSketcherSolver
from SketcherTests.TestSketchFillet import TestSketchFillet
from SketcherTests.TestSketchExpression import TestSketchExpression
from SketcherTests.TestSketchValidateCoincidents import TestSketchValidateCoincidents
from SketcherTests.TestSketchCarbonCopyReverseMapping import TestSketchCarbonCopyReverseMapping
# GUI-dependent tests - only import if GUI is available
try: