Draft: New path array features - reverse path, etc (#19017)
Some missing quality-of-life features in Path Array: 1. A setting to reverse path 2. A "Fixed spacing" mode 3. Ability to use spacing patterns
This commit is contained in:
@@ -131,7 +131,45 @@ class PathArray(DraftLink):
|
||||
|
||||
EndOffset: float
|
||||
It defaults to 0.0.
|
||||
It is the length from the end of the path to the last copy.
|
||||
It is the length at the end of the path that will not be available
|
||||
for object placement.
|
||||
|
||||
ReversePath: bool
|
||||
It defaults to False.
|
||||
This will walk the path in reverse, also reversing object
|
||||
orientation. Start and end offsets will count from opposite ends
|
||||
of the path, etc.
|
||||
|
||||
SpacingMode: string
|
||||
It defaults to `'Fixed count'`.
|
||||
Objects can be spaced to divide the available length evenly
|
||||
(`'Fixed count'`, this is the original spacing mode from FreeCAD 1.0),
|
||||
or to be placed in given distances along the path from each other:
|
||||
`'Fixed spacing'` will keep placing objects for as long as there
|
||||
is still space available, while `'Fixed count and spacing'`
|
||||
will place a given number of objects (provided they fit in available
|
||||
space).
|
||||
|
||||
SpacingUnit: length
|
||||
It defaults to 20mm.
|
||||
When fixed spacing modes are used, this is the spacing distance
|
||||
used. If UseSpacingPattern is also enabled, this is the unit length
|
||||
of "1.0" in the spacing pattern (so, default pattern of [1.0, 2.0]
|
||||
with default SpacingUnit of 20mm means a spacing pattern of
|
||||
20mm, 40mm).
|
||||
|
||||
UseSpacingPattern: bool
|
||||
Default is False.
|
||||
Enables the SpacingPattern for uneven distribution of objects.
|
||||
Will have slightly different effect depending on SpacingMode.
|
||||
|
||||
SpacingPattern: float list
|
||||
Default is [1.0, 2.0]
|
||||
When UseSpacingPattern is True, this list contains the proportions
|
||||
of distances between consecutive object pairs. Can be used in any
|
||||
spacing mode. In "fixed spacing" modes SpacingPattern is multiplied
|
||||
by SpacingUnit. In flexible spacing modes ("fixed count"), spacing
|
||||
pattern defines the proportion of distances.
|
||||
"""
|
||||
|
||||
def __init__(self, obj):
|
||||
@@ -168,6 +206,7 @@ class PathArray(DraftLink):
|
||||
properties = []
|
||||
|
||||
self.set_general_properties(obj, properties)
|
||||
self.set_spacing_properties(obj, properties)
|
||||
self.set_align_properties(obj, properties)
|
||||
|
||||
def set_general_properties(self, obj, properties):
|
||||
@@ -214,14 +253,6 @@ class PathArray(DraftLink):
|
||||
_tip)
|
||||
obj.Fuse = False
|
||||
|
||||
if "Count" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Number of copies to create")
|
||||
obj.addProperty("App::PropertyInteger",
|
||||
"Count",
|
||||
"Objects",
|
||||
_tip)
|
||||
obj.Count = 4
|
||||
|
||||
if self.use_link and "ExpandArray" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Show the individual array elements (only for Link arrays)")
|
||||
obj.addProperty("App::PropertyBool",
|
||||
@@ -271,24 +302,16 @@ class PathArray(DraftLink):
|
||||
"AlignMode",
|
||||
"Alignment",
|
||||
_tip)
|
||||
obj.AlignMode = ['Original', 'Frenet', 'Tangent']
|
||||
obj.AlignMode = 'Original'
|
||||
obj.AlignMode = ["Original", "Frenet", "Tangent"]
|
||||
obj.AlignMode = "Original"
|
||||
|
||||
if "StartOffset" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Length from the start of the path to the first copy.")
|
||||
obj.addProperty("App::PropertyLength",
|
||||
"StartOffset",
|
||||
if "ReversePath" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Walk the path backwards.")
|
||||
obj.addProperty("App::PropertyBool",
|
||||
"ReversePath",
|
||||
"Alignment",
|
||||
_tip)
|
||||
obj.StartOffset = 0.0
|
||||
|
||||
if "EndOffset" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Length from the end of the path to the last copy.")
|
||||
obj.addProperty("App::PropertyLength",
|
||||
"EndOffset",
|
||||
"Alignment",
|
||||
_tip)
|
||||
obj.EndOffset = 0.0
|
||||
obj.ReversePath = False
|
||||
|
||||
# The Align property must be attached after other align properties
|
||||
# so that onChanged works properly
|
||||
@@ -300,6 +323,73 @@ class PathArray(DraftLink):
|
||||
_tip)
|
||||
obj.Align = False
|
||||
|
||||
def set_spacing_properties(self, obj, properties):
|
||||
|
||||
if "Count" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Number of copies to create")
|
||||
obj.addProperty("App::PropertyInteger",
|
||||
"Count",
|
||||
"Spacing",
|
||||
_tip)
|
||||
obj.Count = 4
|
||||
|
||||
if "SpacingMode" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP(
|
||||
"App::Property",
|
||||
"How copies are spaced.\n" +
|
||||
" - Fixed count: available path length (minus start and end offsets) is evenly divided into n.\n" +
|
||||
" - Fixed spacing: start at \"Start offset\" and place new copies after traveling a fixed distance along the path.\n" +
|
||||
" - Fixed count and spacing: same as \"Fixed spacing\", but also stop at given number of copies."
|
||||
)
|
||||
obj.addProperty("App::PropertyEnumeration",
|
||||
"SpacingMode",
|
||||
"Spacing",
|
||||
_tip)
|
||||
obj.SpacingMode = ["Fixed count", "Fixed spacing", "Fixed count and spacing"]
|
||||
obj.SpacingMode = "Fixed count"
|
||||
|
||||
if "SpacingUnit" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Base fixed distance between elements.")
|
||||
obj.addProperty("App::PropertyLength",
|
||||
"SpacingUnit",
|
||||
"Spacing",
|
||||
_tip)
|
||||
obj.SpacingUnit = 20.0
|
||||
obj.setPropertyStatus("SpacingUnit", "Hidden")
|
||||
|
||||
if "UseSpacingPattern" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Use repeating spacing patterns instead of uniform spacing.")
|
||||
obj.addProperty("App::PropertyBool",
|
||||
"UseSpacingPattern",
|
||||
"Spacing",
|
||||
_tip)
|
||||
obj.UseSpacingPattern = False
|
||||
|
||||
if "SpacingPattern" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Spacing is multiplied by a corresponding number in this sequence.")
|
||||
obj.addProperty("App::PropertyFloatList",
|
||||
"SpacingPattern",
|
||||
"Spacing",
|
||||
_tip)
|
||||
obj.SpacingPattern = [1, 2]
|
||||
obj.setPropertyStatus("SpacingPattern", "Hidden")
|
||||
|
||||
if "StartOffset" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Length from the start of the path to the first copy.")
|
||||
obj.addProperty("App::PropertyLength",
|
||||
"StartOffset",
|
||||
"Spacing",
|
||||
_tip)
|
||||
obj.StartOffset = 0.0
|
||||
|
||||
if "EndOffset" not in properties:
|
||||
_tip = QT_TRANSLATE_NOOP("App::Property","Length from the end of the path to the last copy.")
|
||||
obj.addProperty("App::PropertyLength",
|
||||
"EndOffset",
|
||||
"Spacing",
|
||||
_tip)
|
||||
obj.EndOffset = 0.0
|
||||
|
||||
def linkSetup(self, obj):
|
||||
"""Set up the object as a link object."""
|
||||
super().linkSetup(obj)
|
||||
@@ -341,7 +431,12 @@ class PathArray(DraftLink):
|
||||
obj.ForceVertical,
|
||||
obj.VerticalVector,
|
||||
obj.StartOffset.Value,
|
||||
obj.EndOffset.Value)
|
||||
obj.EndOffset.Value,
|
||||
obj.ReversePath,
|
||||
obj.SpacingMode,
|
||||
obj.SpacingUnit.Value,
|
||||
obj.UseSpacingPattern,
|
||||
obj.SpacingPattern)
|
||||
|
||||
self.buildShape(obj, array_placement, copy_placements)
|
||||
self.props_changed_clear()
|
||||
@@ -382,6 +477,40 @@ class PathArray(DraftLink):
|
||||
more than once in a seemingly random order.
|
||||
"""
|
||||
# The minus sign removes the Hidden property (show).
|
||||
|
||||
if prop == "SpacingMode":
|
||||
|
||||
# Check if all referenced properties are available:
|
||||
for pr in ("SpacingMode",
|
||||
"SpacingUnit",
|
||||
"UseSpacingPattern",
|
||||
"SpacingPattern"):
|
||||
if not hasattr(obj, pr):
|
||||
return
|
||||
|
||||
if obj.SpacingMode == "Fixed spacing":
|
||||
obj.setPropertyStatus("Count", "Hidden")
|
||||
obj.setPropertyStatus("SpacingUnit", "-Hidden")
|
||||
|
||||
elif obj.SpacingMode == "Fixed count":
|
||||
obj.setPropertyStatus("Count", "-Hidden")
|
||||
obj.setPropertyStatus("SpacingUnit", "Hidden")
|
||||
|
||||
elif obj.SpacingMode == "Fixed count and spacing":
|
||||
obj.setPropertyStatus("Count", "-Hidden")
|
||||
obj.setPropertyStatus("SpacingUnit", "-Hidden")
|
||||
|
||||
if prop == "UseSpacingPattern":
|
||||
|
||||
# Check if referenced property is available:
|
||||
if not hasattr(obj, "SpacingPattern"):
|
||||
return
|
||||
|
||||
if obj.UseSpacingPattern:
|
||||
obj.setPropertyStatus("SpacingPattern", "-Hidden")
|
||||
else:
|
||||
obj.setPropertyStatus("SpacingPattern", "Hidden")
|
||||
|
||||
if prop in ("Align", "AlignMode"):
|
||||
|
||||
# Check if all referenced properties are available:
|
||||
@@ -412,9 +541,19 @@ class PathArray(DraftLink):
|
||||
|
||||
def onDocumentRestored(self, obj):
|
||||
super().onDocumentRestored(obj)
|
||||
# Fuse property was added in v1.0, obj should be OK if it is present:
|
||||
if hasattr(obj, "Fuse"):
|
||||
# Run updates in order:
|
||||
self.ensure_updated(obj)
|
||||
|
||||
def ensure_updated(self, obj):
|
||||
# ReversePath was added together with several Spacing properties in v1.1.
|
||||
# V1.1 props should be OK if it is present.
|
||||
if hasattr(obj, "ReversePath"):
|
||||
return
|
||||
|
||||
# Fuse property was added in v1.0. Check if it is already present to
|
||||
# correctly issue warning.
|
||||
fuse_was_present = hasattr(obj, "Fuse")
|
||||
|
||||
self.set_properties(obj)
|
||||
if hasattr(obj, "PathObj"):
|
||||
_wrn("v0.19, " + obj.Label + ", " + translate("draft", "migrated 'PathObj' property to 'PathObject'"))
|
||||
@@ -428,7 +567,11 @@ class PathArray(DraftLink):
|
||||
_wrn("v0.19, " + obj.Label + ", " + translate("draft", "migrated 'Xlate' property to 'ExtraTranslation'"))
|
||||
obj.ExtraTranslation = obj.Xlate
|
||||
obj.removeProperty("Xlate")
|
||||
_wrn("v1.0, " + obj.Label + ", " + translate("draft", "added 'Fuse' property"))
|
||||
if not fuse_was_present:
|
||||
_wrn("v1.0, " + obj.Label + ", " + translate("draft", "added 'Fuse' property"))
|
||||
obj.setGroupOfProperty("Count", "Spacing")
|
||||
_wrn("v1.1, " + obj.Label + ", " + translate("draft", "moved 'Count' to 'Spacing' subsection"))
|
||||
_wrn("v1.1, " + obj.Label + ", " + translate("draft", "added 'ReversePath', 'SpacingMode', 'SpacingUnit', 'UseSpacingPattern' and 'SpacingPattern' properties"))
|
||||
|
||||
|
||||
# Alias for compatibility with v0.18 and earlier
|
||||
@@ -438,11 +581,17 @@ _PathArray = PathArray
|
||||
def placements_on_path(shapeRotation, pathwire, count, xlate, align,
|
||||
mode="Original", forceNormal=False,
|
||||
normalOverride=None,
|
||||
startOffset=0.0, endOffset=0.0):
|
||||
startOffset=0.0, endOffset=0.0,
|
||||
reversePath=False,
|
||||
spacingMode="Fixed count",
|
||||
spacingUnit=20.0,
|
||||
useSpacingPattern=False,
|
||||
spacingPattern=[1, 1, 1, 1]):
|
||||
"""Calculate the placements of a shape along a given path.
|
||||
|
||||
Copies will be distributed evenly.
|
||||
Copies will be distributed according to spacing mode - evenly or in fixed offsets.
|
||||
"""
|
||||
|
||||
if mode == "Frenet":
|
||||
forceNormal = False
|
||||
|
||||
@@ -455,14 +604,18 @@ def placements_on_path(shapeRotation, pathwire, count, xlate, align,
|
||||
|
||||
path = Part.__sortEdges__(pathwire.Edges)
|
||||
|
||||
# if ReversePath is on, walk the path backwards:
|
||||
if reversePath:
|
||||
path = path[::-1]
|
||||
|
||||
# find cumulative edge end distance
|
||||
cdist = 0
|
||||
totalDist = 0
|
||||
ends = []
|
||||
for e in path:
|
||||
cdist += e.Length
|
||||
ends.append(cdist)
|
||||
totalDist += e.Length
|
||||
ends.append(totalDist)
|
||||
|
||||
if startOffset > (cdist - 1e-6):
|
||||
if startOffset > (totalDist - 1e-6):
|
||||
if startOffset != 0:
|
||||
_wrn(
|
||||
translate(
|
||||
@@ -470,11 +623,9 @@ def placements_on_path(shapeRotation, pathwire, count, xlate, align,
|
||||
"Start Offset too large for path length. Using zero instead."
|
||||
)
|
||||
)
|
||||
start = 0
|
||||
else:
|
||||
start = startOffset
|
||||
startOffset = 0
|
||||
|
||||
if endOffset > (cdist - start - 1e-6):
|
||||
if endOffset > (totalDist - startOffset - 1e-6):
|
||||
if endOffset != 0:
|
||||
_wrn(
|
||||
translate(
|
||||
@@ -482,41 +633,99 @@ def placements_on_path(shapeRotation, pathwire, count, xlate, align,
|
||||
"End Offset too large for path length minus Start Offset. Using zero instead."
|
||||
)
|
||||
)
|
||||
end = 0
|
||||
else:
|
||||
end = endOffset
|
||||
endOffset = 0
|
||||
|
||||
totalDist = totalDist - startOffset - endOffset
|
||||
|
||||
useFlexibleSpacing = spacingMode == "Fixed count"
|
||||
useFixedSpacing = spacingMode in ("Fixed spacing", "Fixed count and spacing")
|
||||
|
||||
stopAfterCount = spacingMode in ("Fixed count", "Fixed count and spacing")
|
||||
stopAfterDistance = spacingMode in ("Fixed spacing", "Fixed count and spacing")
|
||||
|
||||
spacingUnit = max(spacingUnit, 0)
|
||||
# protect from infinite loop when step = 0
|
||||
if spacingUnit == 0:
|
||||
_wrn(translate("draft", "Spacing unit of 0 is not allowed, using default"))
|
||||
spacingUnit = totalDist
|
||||
|
||||
# negative spacing steps are not defined
|
||||
spacingPattern = [abs(w) for w in spacingPattern]
|
||||
|
||||
# protect from infinite loop when pattern weights are all zeros
|
||||
if sum(spacingPattern) == 0:
|
||||
spacingPattern = [spacingUnit]
|
||||
|
||||
isClosedPath = DraftGeomUtils.isReallyClosed(pathwire) and not (startOffset or endOffset)
|
||||
|
||||
cdist = cdist - start - end
|
||||
count = max(count, 1)
|
||||
n = count if (DraftGeomUtils.isReallyClosed(pathwire) and not (start or end)) else count - 1
|
||||
n = max(n, 1)
|
||||
step = cdist / n
|
||||
|
||||
if useFlexibleSpacing:
|
||||
# Spaces between objects will stretch to fill available length
|
||||
|
||||
segCount = count if isClosedPath else count - 1
|
||||
segCount = max(segCount, 1)
|
||||
|
||||
if useSpacingPattern:
|
||||
# Available lenth will be non-uniformly divided in proportions from SpacingPattern:
|
||||
fullSpacingPattern = [spacingPattern[i % len(spacingPattern)] for i in range(segCount)]
|
||||
sumWeights = sum(fullSpacingPattern)
|
||||
distPerWeightUnit = totalDist / sumWeights
|
||||
steps = [distPerWeightUnit * weigth for weigth in fullSpacingPattern]
|
||||
|
||||
else:
|
||||
# Available lenght will be evenly divided (the original spacing method):
|
||||
steps = [totalDist / segCount]
|
||||
|
||||
if useFixedSpacing:
|
||||
# Objects will be placed in specified intervals
|
||||
|
||||
if useSpacingPattern:
|
||||
# Intervals will be fixed, but follow a repeating pattern:
|
||||
steps = [spacingUnit * mult for mult in spacingPattern]
|
||||
else:
|
||||
# Each interval will be the same:
|
||||
steps = [spacingUnit]
|
||||
|
||||
|
||||
remains = 0
|
||||
travel = start
|
||||
travel = startOffset
|
||||
endTravel = startOffset + totalDist
|
||||
placements = []
|
||||
|
||||
for i in range(count):
|
||||
i = 0
|
||||
while True:
|
||||
# which edge in path should contain this shape?
|
||||
for j in range(len(ends)):
|
||||
if travel <= ends[j]:
|
||||
iend = j
|
||||
remains = ends[iend] - travel
|
||||
offset = path[iend].Length - remains
|
||||
offset = path[iend].Length - remains if not reversePath else remains
|
||||
break
|
||||
else:
|
||||
# avoids problems with float math travel > ends[-1]
|
||||
iend = len(ends) - 1
|
||||
offset = path[iend].Length
|
||||
offset = path[iend].Length if not reversePath else 0
|
||||
|
||||
# place shape at proper spot on proper edge
|
||||
pt = path[iend].valueAt(get_parameter_from_v0(path[iend], offset))
|
||||
place = calculate_placement(shapeRotation,
|
||||
path[iend], offset,
|
||||
pt, xlate, align, normal,
|
||||
mode, forceNormal)
|
||||
mode, forceNormal,
|
||||
reversePath)
|
||||
placements.append(place)
|
||||
travel += steps[i % len(steps)]
|
||||
i = i + 1
|
||||
|
||||
travel += step
|
||||
# End conditions:
|
||||
if stopAfterDistance and travel > endTravel: break
|
||||
if stopAfterCount and i >= count: break
|
||||
|
||||
# Failsafe:
|
||||
if i > 10_000:
|
||||
_wrn(translate("draft", "Operation would generate too many objects. Aborting"))
|
||||
return placements[0:1]
|
||||
|
||||
return placements
|
||||
|
||||
@@ -527,7 +736,8 @@ calculatePlacementsOnPath = placements_on_path
|
||||
def calculate_placement(globalRotation,
|
||||
edge, offset, RefPt, xlate, align,
|
||||
normal=App.Vector(0.0, 0.0, 1.0),
|
||||
mode="Original", overrideNormal=False):
|
||||
mode="Original", overrideNormal=False,
|
||||
reversePath=False):
|
||||
"""Orient shape in the local coordinate system at parameter offset.
|
||||
|
||||
http://en.wikipedia.org/wiki/Euler_angles (previous version)
|
||||
@@ -535,7 +745,7 @@ def calculate_placement(globalRotation,
|
||||
"""
|
||||
# Default Placement:
|
||||
placement = App.Placement()
|
||||
placement.Rotation = globalRotation
|
||||
placement.Rotation = globalRotation.inverted() if reversePath else globalRotation
|
||||
placement.Base = RefPt + placement.Rotation.multVec(xlate)
|
||||
|
||||
if not align:
|
||||
@@ -545,10 +755,14 @@ def calculate_placement(globalRotation,
|
||||
nullv = App.Vector()
|
||||
|
||||
t = edge.tangentAt(get_parameter_from_v0(edge, offset))
|
||||
|
||||
if t.isEqual(nullv, tol):
|
||||
_wrn(translate("draft", "Length of tangent vector is zero. Copy not aligned."))
|
||||
return placement
|
||||
|
||||
if reversePath:
|
||||
t.multiply(-1)
|
||||
|
||||
# If the length of the normal is zero or if it is parallel to the tangent,
|
||||
# we make the vectors equal (n = t). The App.Rotation() algorithm will
|
||||
# then replace the normal with a default axis.
|
||||
@@ -570,9 +784,9 @@ def calculate_placement(globalRotation,
|
||||
n = t
|
||||
|
||||
if overrideNormal:
|
||||
newRot = App.Rotation(t, nullv, n, "XZY") # priority = "XZY"
|
||||
onPathRotation = App.Rotation(t, nullv, n, "XZY") # priority = "XZY"
|
||||
else:
|
||||
newRot = App.Rotation(t, n, nullv, "XYZ") # priority = "XYZ"
|
||||
onPathRotation = App.Rotation(t, n, nullv, "XYZ") # priority = "XYZ"
|
||||
|
||||
elif mode == "Frenet":
|
||||
try:
|
||||
@@ -591,20 +805,20 @@ def calculate_placement(globalRotation,
|
||||
_wrn(translate("draft", "Tangent and normal vectors are parallel. Normal replaced by a default axis."))
|
||||
n = t
|
||||
|
||||
newRot = App.Rotation(t, n, nullv, "XYZ") # priority = "XYZ"
|
||||
onPathRotation = App.Rotation(t, n, nullv, "XYZ") # priority = "XYZ"
|
||||
|
||||
else:
|
||||
_err(translate("draft", "AlignMode {} is not implemented").format(mode))
|
||||
return placement
|
||||
|
||||
placement.Rotation = newRot.multiply(globalRotation)
|
||||
placement.Rotation = onPathRotation.multiply(globalRotation)
|
||||
placement.Base = RefPt + placement.Rotation.multVec(xlate)
|
||||
|
||||
return placement
|
||||
|
||||
|
||||
calculatePlacement = calculate_placement
|
||||
|
||||
|
||||
def get_parameter_from_v0(edge, offset):
|
||||
"""Return parameter at distance offset from edge.Vertexes[0].
|
||||
|
||||
|
||||
Reference in New Issue
Block a user