freecad-cam/Mod/CAM/Path/Dressup/DogboneII.py
2026-02-01 01:59:24 +01:00

360 lines
13 KiB
Python

# -*- coding: utf-8 -*-
# ***************************************************************************
# * Copyright (c) 2022 sliptonic <shopinthewoods@gmail.com> *
# * *
# * 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 *
# * *
# ***************************************************************************
from PySide.QtCore import QT_TRANSLATE_NOOP
import FreeCAD
import Path
import Path.Base.Generator.dogboneII as dogboneII
import Path.Base.Language as PathLanguage
import Path.Dressup.Utils as PathDressup
import PathScripts.PathUtils as PathUtils
import math
if False:
Path.Log.setLevel(Path.Log.Level.DEBUG, Path.Log.thisModule())
Path.Log.trackModule(Path.Log.thisModule())
PI = math.pi
def calc_length_adaptive(kink, angle, nominal_length, custom_length):
Path.Log.track(kink, angle, nominal_length, custom_length)
if Path.Geom.isRoughly(abs(kink.deflection()), 0):
return 0
# If the kink poses a 180deg turn the adaptive length is undefined. Mathematically
# it's infinite but that is not practical.
# We define the adaptive length to be the nominal length for this case.
if Path.Geom.isRoughly(abs(kink.deflection()), PI):
return nominal_length
# The distance of the (estimated) corner from the kink position depends only on the
# deflection of the kink.
# Some sample values to build up intuition:
# deflection : dog bone : norm distance : calc
# ----------------:-------------:---------------:--------------
# 0 : -PI/2 : 1
# PI/6 : -5*PI/12 : 1.03528 : 1/cos( (pi/6) / 2)
# PI/4 : -3*PI/8 : 1.08239 : 1/cos( (pi/4) / 2)
# PI/3 : -PI/3 : 1.1547 : 1/cos( (pi/3) / 2)
# PI/2 : -PI/4 : 1.41421 : 1/cos( (pi/2) / 2)
# 2*PI/3 : -PI/6 : 2 : 1/cos((2*pi/3) / 2)
# 3*PI/4 : -PI/8 : 2.61313 : 1/cos((3*pi/4) / 2)
# 5*PI/6 : -PI/12 : 3.8637 : 1/cos((5*pi/6) / 2)
# PI : 0 : nan <-- see above
# The last column can be geometrically derived or found by experimentation.
dist = nominal_length / math.cos(kink.deflection() / 2)
# The depth of the bone depends on the direction of the bone in relation to the
# direction of the corner. If the direction is identical then the depth is the same
# as the distance of the corner minus the nominal_length (which corresponds to the
# radius of the tool).
# If the corner's direction is PI/4 off the bone angle the intersecion of the tool
# with the corner is the projection of the corner onto the bone.
# If the corner's direction is perpendicular to the bone's angle there is, strictly
# speaking no intersection and the bone is ineffective. However, giving it our
# best shot we should probably move the entire depth.
da = Path.Geom.normalizeAngle(kink.normAngle() - angle)
depth = dist * math.cos(da)
if depth < 0:
Path.Log.debug(
f"depth={depth:4f}: kink={kink}, angle={180*angle/PI}, dist={dist:.4f}, da={180*da/PI} -> depth=0.0"
)
depth = 0
else:
height = dist * abs(math.sin(da))
if height < nominal_length:
depth = depth - math.sqrt(nominal_length * nominal_length - height * height)
Path.Log.debug(
f"{kink}: angle={180*angle/PI}, dist={dist:.4f}, da={180*da/PI}, depth={depth:.4f}"
)
return depth
def calc_length_nominal(kink, angle, nominal_length, custom_length):
return nominal_length
def calc_length_custom(kink, angle, nominal_length, custom_length):
return custom_length
class Style(object):
"""Style - enumeration class for the supported bone styles"""
Dogbone = "Dogbone"
Tbone_H = "T-bone horizontal"
Tbone_V = "T-bone vertical"
Tbone_L = "T-bone long edge"
Tbone_S = "T-bone short edge"
All = [Dogbone, Tbone_H, Tbone_V, Tbone_L, Tbone_S]
Generator = {
Dogbone: dogboneII.GeneratorDogbone,
Tbone_H: dogboneII.GeneratorTBoneHorizontal,
Tbone_V: dogboneII.GeneratorTBoneVertical,
Tbone_S: dogboneII.GeneratorTBoneOnShort,
Tbone_L: dogboneII.GeneratorTBoneOnLong,
}
class Side(object):
"""Side - enumeration class for the side of the path to attach bones"""
Left = "Left"
Right = "Right"
All = [Left, Right]
@classmethod
def oppositeOf(cls, side):
if side == cls.Left:
return cls.Right
if side == cls.Right:
return cls.Left
return None
class Incision(object):
"""Incision - enumeration class for the different depths of bone incision"""
Fixed = "fixed"
Adaptive = "adaptive"
Custom = "custom"
All = [Adaptive, Fixed, Custom]
Calc = {
Fixed: calc_length_nominal,
Adaptive: calc_length_adaptive,
Custom: calc_length_custom,
}
def insertBone(obj, kink):
"""insertBone(kink, side) - return True if a bone should be inserted into the kink"""
if not kink.isKink():
Path.Log.debug(f"not a kink")
return False
if obj.Side == Side.Right and kink.goesRight():
return False
if obj.Side == Side.Left and kink.goesLeft():
return False
return True
class BoneState(object):
def __init__(self, bone, nr, enabled=True):
self.bone = bone
self.bones = {nr: bone}
self.enabled = enabled
pos = bone.position()
self.pos = FreeCAD.Vector(pos.x, pos.y, 0)
def isEnabled(self):
return self.enabled
def addBone(self, bone, nr):
self.bones[nr] = bone
def position(self):
return self.pos
def boneTip(self):
return self.bone.tip()
def boneIDs(self):
return sorted(self.bones)
def zLevels(self):
return sorted([bone.position().z for bone in self.bones.values()])
def length(self):
return self.bone.length
class Proxy(object):
def __init__(self, obj, base):
obj.addProperty(
"App::PropertyLink",
"Base",
"Base",
QT_TRANSLATE_NOOP("App::Property", "The base path to dress up"),
)
obj.Base = base
obj.addProperty(
"App::PropertyEnumeration",
"Side",
"Dressup",
QT_TRANSLATE_NOOP("App::Property", "The side of path to insert bones"),
)
obj.Side = Side.All
if hasattr(base, "BoneBlacklist"):
obj.Side = base.Side
else:
side = Side.Right
if hasattr(obj.Base, "Side") and obj.Base.Side == "Inside":
side = Side.Left
if hasattr(obj.Base, "Direction") and obj.Base.Direction == "CCW":
side = Side.oppositeOf(side)
obj.Side = side
obj.addProperty(
"App::PropertyEnumeration",
"Style",
"Dressup",
QT_TRANSLATE_NOOP("App::Property", "The style of bones"),
)
obj.Style = Style.All
obj.Style = Style.Dogbone
obj.addProperty(
"App::PropertyEnumeration",
"Incision",
"Dressup",
QT_TRANSLATE_NOOP("App::Property", "The algorithm to determine the bone length"),
)
obj.Incision = Incision.All
obj.Incision = Incision.Adaptive
obj.addProperty(
"App::PropertyLength",
"Custom",
"Dressup",
QT_TRANSLATE_NOOP("App::Property", "Dressup length if incision is set to 'custom'"),
)
obj.Custom = 0.0
obj.addProperty(
"App::PropertyIntegerList",
"BoneBlacklist",
"Dressup",
QT_TRANSLATE_NOOP("App::Property", "Bones that aren't dressed up"),
)
obj.BoneBlacklist = []
self.onDocumentRestored(obj)
def onDocumentRestored(self, obj):
self.obj = obj
obj.setEditorMode("BoneBlacklist", 2) # hide
def dumps(self):
return None
def loads(self, state):
return None
def toolRadius(self, obj):
return PathDressup.toolController(obj.Base).Tool.Diameter.Value / 2
def createBone(self, obj, move0, move1):
kink = dogboneII.Kink(move0, move1)
Path.Log.debug(f"{obj.Label}.createBone({kink})")
if insertBone(obj, kink):
generator = Style.Generator[obj.Style]
calc_length = Incision.Calc[obj.Incision]
nominal = self.toolRadius(obj)
custom = obj.Custom.Value
return dogboneII.generate(kink, generator, calc_length, nominal, custom)
return None
def execute(self, obj):
Path.Log.track(obj.Label)
maneuver = PathLanguage.Maneuver()
bones = []
lastMove = None
moveAfterPlunge = None
dressingUpDogbone = hasattr(obj.Base, "BoneBlacklist")
if obj.Base and obj.Base.Path and obj.Base.Path.Commands:
for i, instr in enumerate(
PathLanguage.Maneuver.FromPath(PathUtils.getPathWithPlacement(obj.Base)).instr
):
# Path.Log.debug(f"instr: {instr}")
if instr.isMove():
thisMove = instr
bone = None
if thisMove.isPlunge():
if lastMove and moveAfterPlunge and lastMove.leadsInto(moveAfterPlunge):
bone = self.createBone(obj, lastMove, moveAfterPlunge)
lastMove = None
moveAfterPlunge = None
else:
if moveAfterPlunge is None:
moveAfterPlunge = thisMove
if lastMove:
bone = self.createBone(obj, lastMove, thisMove)
lastMove = thisMove
if bone:
enabled = not len(bones) in obj.BoneBlacklist
if enabled and not (
dressingUpDogbone and obj.Base.Proxy.includesBoneAt(bone.position())
):
maneuver.addInstructions(bone.instr)
else:
Path.Log.debug(f"{bone.kink} disabled {enabled}")
bones.append(bone)
maneuver.addInstruction(thisMove)
else:
# non-move instructions get added verbatim
maneuver.addInstruction(instr)
else:
Path.Log.info(f"No Path found to dress up in op {obj.Base}")
self.maneuver = maneuver
self.bones = bones
self.boneTips = None
obj.Path = maneuver.toPath()
def boneStates(self, obj):
state = {}
if hasattr(self, "bones"):
for nr, bone in enumerate(self.bones):
pos = bone.position()
loc = f"({pos.x:.4f}, {pos.y:.4f})"
if state.get(loc, None):
state[loc].addBone(bone, nr)
else:
state[loc] = BoneState(bone, nr)
if nr in obj.BoneBlacklist:
state[loc].enabled = False
return state.values()
def includesBoneAt(self, pos):
if hasattr(self, "bones"):
for nr, bone in enumerate(self.bones):
if Path.Geom.pointsCoincide(bone.position(), pos):
return not (nr in self.obj.BoneBlacklist)
return False
def Create(base, name="DressupDogbone"):
obj = FreeCAD.ActiveDocument.addObject("Path::FeaturePython", name)
pxy = Proxy(obj, base)
obj.Proxy = pxy
return obj