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:
Paweł Pohl
2025-02-05 15:16:25 +01:00
committed by GitHub
parent 703a1b52ff
commit aef46ad56d

View File

@@ -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].