Repository navigation
Expand file tree
/
Copy pathSolver.lua
More file actions
519 lines (478 loc) · 24 KB
/
Copy pathSolver.lua
File metadata and controls
519 lines (478 loc) · 24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
local addonName, NS = ...
-- ============================================================
-- SOLVER
-- Pure geometry. No frames, no WoW globals. All coordinates are UIParent
-- units relative to UIParent CENTER. rect = {x=cx, y=cy, w, h}.
-- ============================================================
local S = {}
NS.Solver = S
local floor, abs, max, min, sqrt, huge = math.floor, math.abs, math.max, math.min, math.sqrt, math.huge
local ipairs, tinsert = ipairs, table.insert
S.SPACING = 2
-- ============================================================
-- POINT <-> CENTER
-- ============================================================
local H = { LEFT = -1, RIGHT = 1, TOPLEFT = -1, BOTTOMLEFT = -1, TOPRIGHT = 1, BOTTOMRIGHT = 1 }
local V = { TOP = 1, BOTTOM = -1, TOPLEFT = 1, TOPRIGHT = 1, BOTTOMLEFT = -1, BOTTOMRIGHT = -1 }
function S.PointToCenter(point, x, y, w, h)
return x - (H[point] or 0) * w / 2, y - (V[point] or 0) * h / 2
end
function S.CenterToPoint(point, cx, cy, w, h)
return cx + (H[point] or 0) * w / 2, cy + (V[point] or 0) * h / 2
end
-- Drag deltas. The record's point/x/y and the element's visible centre can be
-- offset from each other; moving the visible centre by (dx, dy) moves the
-- record by the same (dx, dy), whatever that offset is.
function S.DragDelta(startPos, startCx, startCy, cx, cy)
return (startPos.x or 0) + (cx - startCx), (startPos.y or 0) + (cy - startCy)
end
-- The X/Y pair the element panel's boxes show and the slab's coords readout repeats:
-- the record's x/y for a free element, the anchor's offsets for an anchored one,
-- rounded half up. ONE producer for both: the visible rect's centre is a different
-- quantity whenever the record's point is not CENTER or the consumer's rect is offset
-- from its record (DF's raid frames sit inside a larger reserved container).
--
-- A MIRRORED axis shows its Mirror Position instead of its offset: on that axis the
-- offset is derived (see MIRROR below) and the box edits the centre line.
function S.Readout(pos)
local a = pos.anchor
if a then
local x = a.mirrorX and (a.mirrorPosX or 0) or (a.offsetX or 0)
local y = a.mirrorY and (a.mirrorPosY or 0) or (a.offsetY or 0)
return floor(x + 0.5), floor(y + 0.5)
end
return floor((pos.x or 0) + 0.5), floor((pos.y or 0) + 0.5)
end
-- Nudge distance for one arrow press / click. Ctrl wins over Shift so
-- Shift+Ctrl is still x100, not an undefined mix.
function S.NudgeStep(shift, ctrl)
if ctrl then return 100 end
if shift then return 10 end
return 1
end
-- ============================================================
-- GRID / SCREEN SNAP
-- ============================================================
function S.SnapToGrid(x, y, gridSize)
if not gridSize or gridSize <= 0 then return x, y end
return floor(x / gridSize + 0.5) * gridSize, floor(y / gridSize + 0.5) * gridSize
end
-- Grid snap that considers the rect's centre AND its edges per axis, and moves
-- the rect by whichever lands on a grid line with the smallest shift. This is
-- what lets a frame's left edge or top edge sit exactly on the grid, not only
-- its centre.
--
-- Returns cx, cy, lineX, lineY: the snapped centre plus, per axis, the grid
-- line the winning candidate (centre or edge) now sits on -- the line a snap
-- preview should draw, which is NOT the centre when an edge is what snapped.
-- A candidate already on a line wins with a zero shift and still reports its
-- line. Both lines are nil when there is no grid to snap to.
function S.SnapRectToGrid(cx, cy, w, h, gridSize)
if not gridSize or gridSize <= 0 then return cx, cy, nil, nil end
local function best(center, half)
local candidates = { center, center - half, center + half }
local bestShift, bestAbs, bestLine = 0, math.huge, nil
for _, v in ipairs(candidates) do
local snapped = floor(v / gridSize + 0.5) * gridSize
local shift = snapped - v
if abs(shift) < bestAbs then bestAbs, bestShift, bestLine = abs(shift), shift, snapped end
end
return center + bestShift, bestLine
end
local nx, lineX = best(cx, w / 2)
local ny, lineY = best(cy, h / 2)
return nx, ny, lineX, lineY
end
-- Snaps the rect's nearest edge or its centre to the screen edges / centre lines.
-- Returns cx, cy, sx, sy, lineX, lineY: the snapped centre, whether each axis
-- snapped, and the screen line (0 or +-half the screen) it snapped to -- nil on
-- an axis that did not snap.
function S.SnapToScreen(rect, screenW, screenH, threshold)
local cx, cy = rect.x, rect.y
local hw, hh = rect.w / 2, rect.h / 2
local halfW, halfH = screenW / 2, screenH / 2
local sx, sy = false, false
local lineX, lineY
local candX = { { cx, 0 }, { cx - hw, -halfW }, { cx + hw, halfW } }
local candY = { { cy, 0 }, { cy - hh, -halfH }, { cy + hh, halfH } }
local bestD = threshold
for _, c in ipairs(candX) do
local d = abs(c[1] - c[2])
if d <= bestD then bestD = d; cx = rect.x + (c[2] - c[1]); sx = true; lineX = c[2] end
end
bestD = threshold
for _, c in ipairs(candY) do
local d = abs(c[1] - c[2])
if d <= bestD then bestD = d; cy = rect.y + (c[2] - c[1]); sy = true; lineY = c[2] end
end
return cx, cy, sx, sy, lineX, lineY
end
-- Keeps the rect inside the screen (UIParent units). Elements larger than the
-- screen are pinned so their top-left corner stays reachable.
function S.ClampToScreen(cx, cy, w, h, screenW, screenH)
local hw, hh, halfW, halfH = w / 2, h / 2, screenW / 2, screenH / 2
if cx + hw > halfW then cx = halfW - hw end
if cx - hw < -halfW then cx = -halfW + hw end
if cy + hh > halfH then cy = halfH - hh end
if cy - hh < -halfH then cy = -halfH + hh end
return cx, cy
end
-- Area of the intersection of two centre-based rects; 0 when they only touch
-- or are apart.
function S.RectOverlapArea(a, b)
local w = min(a.x + a.w / 2, b.x + b.w / 2) - max(a.x - a.w / 2, b.x - b.w / 2)
local h = min(a.y + a.h / 2, b.y + b.h / 2) - max(a.y - a.h / 2, b.y - b.h / 2)
if w > 0 and h > 0 then return w * h end
return 0
end
-- The LOOSE clamp: a rect with any part on screen is left exactly where it is;
-- one with nothing on screen is pulled to the nearest fully-visible spot. What
-- an anchored solve gets (Core's NS.KeepOnScreen) and -- the same function, so
-- the two can never disagree -- where a slab is drawn (Proxy's syncGeometry).
-- Free elements never reach the second branch: every write that moves one
-- clamps it fully (ClampToScreen above).
-- Scalar maths rather than RectOverlapArea: the slab path runs per refresh and
-- would otherwise build two tables every time.
function S.KeepOnScreen(cx, cy, w, h, screenW, screenH)
local halfW, halfH = screenW / 2, screenH / 2
local ow = min(cx + w / 2, halfW) - max(cx - w / 2, -halfW)
local oh = min(cy + h / 2, halfH) - max(cy - h / 2, -halfH)
if ow > 0 and oh > 0 then return cx, cy end
return S.ClampToScreen(cx, cy, w, h, screenW, screenH)
end
-- ============================================================
-- PANEL DOCKING
-- ============================================================
-- Candidate panel positions beside a proxy rect, in tie-break priority order
-- (right > left > below > above). right/left hang from the proxy's TOP edge --
-- the panel's historical dock -- while above/below centre on it.
function S.DockCandidates(proxy, panelW, panelH, gap)
local top = proxy.y + proxy.h / 2
return {
{ side = "right", x = proxy.x + proxy.w / 2 + gap + panelW / 2, y = top - panelH / 2 },
{ side = "left", x = proxy.x - proxy.w / 2 - gap - panelW / 2, y = top - panelH / 2 },
{ side = "below", x = proxy.x, y = proxy.y - proxy.h / 2 - gap - panelH / 2 },
{ side = "above", x = proxy.x, y = proxy.y + proxy.h / 2 + gap + panelH / 2 },
}
end
-- The dock side that covers the least: each fully-on-screen candidate is scored
-- by its total overlap area against the obstacle rects, smallest wins. Ties
-- keep the earlier candidate, so DockCandidates' order IS the tie-break.
-- nil when no candidate fits on screen (the caller falls back to an edge flip).
function S.BestDockSide(proxy, panelW, panelH, gap, obstacles, screenW, screenH)
local halfW, halfH = screenW / 2, screenH / 2
local best, bestOverlap
for _, c in ipairs(S.DockCandidates(proxy, panelW, panelH, gap)) do
if c.x - panelW / 2 >= -halfW and c.x + panelW / 2 <= halfW
and c.y - panelH / 2 >= -halfH and c.y + panelH / 2 <= halfH then
local rect = { x = c.x, y = c.y, w = panelW, h = panelH }
local overlap = 0
for _, o in ipairs(obstacles) do overlap = overlap + S.RectOverlapArea(rect, o) end
if overlap == 0 then return c.side end -- nothing beats zero at this priority
if not bestOverlap or overlap < bestOverlap then best, bestOverlap = c.side, overlap end
end
end
return best
end
-- ============================================================
-- ANCHOR RESOLUTION
-- ============================================================
-- The seat with NO offsets: where the anchor's edge/align (or point/relPoint)
-- alone puts the child's centre. Resolve adds the offsets; OffsetsFor inverts it.
local function seat(anchor, childW, childH, parent, spacing)
-- Point mode: child:SetPoint(point, target, relPoint, 0, 0).
if anchor.mode == "point" then
local pt, rel = anchor.point or "CENTER", anchor.relPoint or "CENTER"
local tx = parent.x + (H[rel] or 0) * parent.w / 2
local ty = parent.y + (V[rel] or 0) * parent.h / 2
return tx - (H[pt] or 0) * childW / 2, ty - (V[pt] or 0) * childH / 2
end
spacing = spacing or S.SPACING
local hpw, hph = parent.w / 2, parent.h / 2
local hcw, hch = childW / 2, childH / 2
local edge, align = anchor.edge or "right", anchor.align or "center"
local x, y = parent.x, parent.y
if edge == "right" then x = parent.x + hpw + spacing + hcw
elseif edge == "left" then x = parent.x - hpw - spacing - hcw
elseif edge == "top" then y = parent.y + hph + spacing + hch
elseif edge == "bottom" then y = parent.y - hph - spacing - hch end
if edge == "right" or edge == "left" then
if align == "start" then y = parent.y + hph - hch
elseif align == "end" then y = parent.y - hph + hch end
else
if align == "start" then x = parent.x - hpw + hcw
elseif align == "end" then x = parent.x + hpw - hcw end
end
return x, y
end
local function validParent(parent)
return parent and parent.w and parent.h and parent.w > 0 and parent.h > 0
end
-- The child's centre for an anchor block against its parent's rect. A mirrored axis
-- ignores the seat and the offset on that axis and reflects the parent's centre
-- across the anchor's centre line instead (see MIRROR below).
function S.Resolve(anchor, childW, childH, parent, spacing)
if not validParent(parent) then return nil end
local x, y = seat(anchor, childW, childH, parent, spacing)
x, y = x + (anchor.offsetX or 0), y + (anchor.offsetY or 0)
if anchor.mirrorX then x = S.Reflect(parent.x, anchor.mirrorPosX or 0) end
if anchor.mirrorY then y = S.Reflect(parent.y, anchor.mirrorPosY or 0) end
return x, y
end
-- The plain offsets that put the child's centre at (cx, cy) on this anchor's seat:
-- the inverse of Resolve with the mirror ignored. What a mirrored axis keeps in its
-- offset field, and what that axis becomes when the mirror is turned off.
function S.OffsetsFor(anchor, childW, childH, parent, spacing, cx, cy)
if not validParent(parent) then return nil end
local x, y = seat(anchor, childW, childH, parent, spacing)
return cx - x, cy - y
end
-- ============================================================
-- MIRROR
-- An anchored child can mirror its parent on either axis. On a mirrored axis the
-- child's CENTRE is the reflection of the parent's CENTRE across a centre line --
-- centres, not edges or points, so frames of different sizes still look symmetric.
-- The line sits at the anchor's mirrorPosX / mirrorPosY, measured like every other
-- coordinate here from UIParent CENTER: 0 is the screen centre. Coordinates are
-- UIParent units (Registry measures rects through effective scale), so the
-- reflection is in screen space whatever either frame's scale is.
--
-- Record fields, PRIMARY anchor block only (a backup never mirrors):
-- mirrorX / mirrorY true or nil
-- mirrorPosX / mirrorPosY the centre line; nil reads as 0
-- offsetX / offsetY stay numbers on a mirrored axis: every solve rewrites them to
-- the plain offset that reproduces where the child is (OffsetsFor), so a reader
-- that knows nothing of mirroring still places the child where it last was.
-- ============================================================
function S.Reflect(c, line) return 2 * line - c end
-- The centre line two centres mirror around: their midpoint. Always exists, so
-- turning a mirror on can always keep the child exactly where it is.
function S.MirrorLine(c1, c2) return (c1 + c2) / 2 end
-- One axis of the PAIR clamp. c is a centre, half its half-size; the partner is the
-- reflection of c across line with half-size partnerHalf. Returns c pulled into the
-- range where BOTH rects are fully inside [-screenHalf, screenHalf], so a clamp can
-- never silently break the symmetry. When no such range exists (the line is so far
-- off centre that one of the two must leave the screen) c comes back untouched and
-- the caller's own single-element clamp is the one that applies.
function S.MirrorClampAxis(c, half, partnerHalf, line, screenHalf)
local lo = max(-screenHalf + half, 2 * line - screenHalf + partnerHalf)
local hi = min(screenHalf - half, 2 * line + screenHalf - partnerHalf)
if lo > hi then return c end
if c < lo then return lo end
if c > hi then return hi end
return c
end
-- ============================================================
-- ANCHOR IN PLACE
-- The anchor spec that reproduces where the child ALREADY is: feeding it back
-- through S.Resolve returns the child's current centre, so attaching an element
-- to a parent never moves it.
--
-- Outside mode when the child is genuinely clear of one of the target's edges.
-- The edge is picked by CLEARANCE (the edge-to-edge gap), not by which residual
-- offset is smallest, so an element parked far out to the right stays "right of"
-- its target however far out it is. Ties go right > left > top > bottom. The
-- align is then whichever of start/center/end leaves the smallest cross-axis
-- residual, ties keeping the earlier one.
--
-- Everything else has no honest edge/align seat -- overlapping the target, sitting
-- inside it, sitting entirely past the edge's span, or a zero-size target -- and
-- falls back to point mode CENTER/CENTER with the centre delta as the offsets.
-- ============================================================
local IN_PLACE_ALIGNS = { "start", "center", "end" }
function S.AnchorInPlace(child, target, spacing)
spacing = spacing or S.SPACING
local function asPoint()
return {
mode = "point", point = "CENTER", relPoint = "CENTER",
offsetX = child.x - target.x, offsetY = child.y - target.y,
}
end
if target.w <= 0 or target.h <= 0 then return asPoint() end
local gapR = (child.x - target.x) - (target.w + child.w) / 2
local gapL = (target.x - child.x) - (target.w + child.w) / 2
local gapT = (child.y - target.y) - (target.h + child.h) / 2
local gapB = (target.y - child.y) - (target.h + child.h) / 2
local edge, gap = "right", gapR
if gapL > gap then edge, gap = "left", gapL end
if gapT > gap then edge, gap = "top", gapT end
if gapB > gap then edge, gap = "bottom", gapB end
if gap < 0 then return asPoint() end -- overlapping or inside: outside mode would be a lie
local sideways = (edge == "right" or edge == "left")
local bestAlign, bestCost, bestX, bestY
for _, align in ipairs(IN_PLACE_ALIGNS) do
local rx, ry = S.Resolve({ edge = edge, align = align }, child.w, child.h, target, spacing)
local cost = sideways and abs(child.y - ry) or abs(child.x - rx)
if not bestCost or cost < bestCost then
bestAlign, bestCost, bestX, bestY = align, cost, rx, ry
end
end
-- Past the far end of the edge's span even at the best align: the child is
-- not beside that edge in any meaningful sense.
local span = sideways and (target.h + child.h) / 2 or (target.w + child.w) / 2
if bestCost > span then return asPoint() end
return { edge = edge, align = bestAlign, offsetX = child.x - bestX, offsetY = child.y - bestY }
end
-- ============================================================
-- SNAP ZONES
-- 12 per target: 4 edges x start/center/end. isOccupied(edge, align) -> bool.
-- ============================================================
local EDGES = { "right", "left", "top", "bottom" }
local ALIGNS = { "start", "center", "end" }
function S.SnapZones(targetId, target, dragW, dragH, spacing, isOccupied)
local zones = {}
if not target or target.w <= 0 or target.h <= 0 then return zones end
for _, edge in ipairs(EDGES) do
for _, align in ipairs(ALIGNS) do
local x, y = S.Resolve({ edge = edge, align = align }, dragW, dragH, target, spacing)
tinsert(zones, {
target = targetId, edge = edge, align = align, x = x, y = y,
occupied = (isOccupied and isOccupied(edge, align)) or false,
})
end
end
return zones
end
-- Largest rectangle overlap with a minimum-fraction floor. Not what the drag uses (see
-- NearestZone); kept as public surface, and its overlap maths is NearestZone's tie-break.
function S.BestZone(cx, cy, w, h, zones, minFraction)
local hw, hh = w / 2, h / 2
local dl, dr, dt, db = cx - hw, cx + hw, cy + hh, cy - hh
local best, bestArea = nil, 0
for _, z in ipairs(zones) do
if not z.occupied then
local ol, orr = max(dl, z.x - hw), min(dr, z.x + hw)
local ob, ot = max(db, z.y - hh), min(dt, z.y + hh)
if orr > ol and ot > ob then
local area = (orr - ol) * (ot - ob)
if area > bestArea then best, bestArea = z, area end
end
end
end
if best and bestArea >= w * h * (minFraction or 0.1) then return best, bestArea end
return nil, 0
end
-- The hit test the drag actually uses: a FIXED radius in screen units, not a
-- fraction of the dragged element. BestZone's overlap rule made the snap radius
-- scale with the frame -- a 500px-wide raid container caught a zone from 250px
-- away while a 60px icon had to be almost on top of it. Here the measure is the
-- edge-to-edge gap between the dragged rect and the zone rect (0 while they
-- overlap), and one setting means the same distance for every element.
--
-- Ties (every overlapping candidate has a gap of 0) fall back to the larger
-- overlap area, which is BestZone's ordering, so a drop that sits over several
-- zones still picks the one it covers most.
local EPSILON = 1e-9
-- The zone whose landing position is nearest to where the element is now,
-- measured centre to centre (= how far the element would jump on drop), and
-- only within snapDistance. Size-independent: a 400-wide container and a
-- 20-wide icon both snap from the same distance. Ties go to the larger overlap.
function S.NearestZone(cx, cy, w, h, zones, snapDistance)
snapDistance = snapDistance or 0
local hw, hh = w / 2, h / 2
local dl, dr, dt, db = cx - hw, cx + hw, cy + hh, cy - hh
local best, bestDist, bestArea = nil, huge, -1
for _, z in ipairs(zones) do
if not z.occupied then
local dx, dy = cx - z.x, cy - z.y
local dist = sqrt(dx * dx + dy * dy)
if dist <= snapDistance + EPSILON then
local ol, orr = max(dl, z.x - hw), min(dr, z.x + hw)
local ob, ot = max(db, z.y - hh), min(dt, z.y + hh)
local area = (orr > ol and ot > ob) and (orr - ol) * (ot - ob) or 0
if dist < bestDist - EPSILON or (dist <= bestDist + EPSILON and area > bestArea) then
best, bestDist, bestArea = z, dist, area
end
end
end
end
if best then return best, bestDist, bestArea end
return nil
end
-- Where an anchor CONNECTS on the target: the edge/align point (outside mode)
-- or the relPoint (point mode). This is the fixed spot a tether attaches to --
-- it never slides as the child moves.
function S.AnchorPointOnTarget(anchor, target)
if not target then return nil end
local hw, hh = target.w / 2, target.h / 2
if anchor.mode == "point" then
local rel = anchor.relPoint or "CENTER"
return S.CenterToPoint(rel, target.x, target.y, target.w, target.h)
end
local edge, align = anchor.edge or "right", anchor.align or "center"
local x, y = target.x, target.y
if edge == "right" then x = target.x + hw
elseif edge == "left" then x = target.x - hw
elseif edge == "top" then y = target.y + hh
elseif edge == "bottom" then y = target.y - hh end
if edge == "right" or edge == "left" then
if align == "start" then y = target.y + hh elseif align == "end" then y = target.y - hh end
else
if align == "start" then x = target.x - hw elseif align == "end" then x = target.x + hw end
end
return x, y
end
-- ============================================================
-- GRAPH
-- ============================================================
function S.WouldCreateCycle(parentOf, childId, targetId)
local cur, seen = targetId, {}
while cur do
if cur == childId then return true end
if seen[cur] then return false end
seen[cur] = true
cur = parentOf(cur)
end
return false
end
-- Parents before children. Ids whose ancestry loops are dropped.
function S.ResolutionOrder(ids, parentOf)
local set = {}
for _, id in ipairs(ids) do set[id] = true end
local order, state = {}, {} -- 1 visiting, 2 done, 3 cyclic
local function visit(id)
if state[id] == 2 then return true end
if state[id] == 1 or state[id] == 3 then return false end
state[id] = 1
local p = parentOf(id)
local ok = true
if p and set[p] then ok = visit(p) end
if ok then state[id] = 2; tinsert(order, id) else state[id] = 3 end
return ok
end
for _, id in ipairs(ids) do visit(id) end
return order
end
function S.ProximityFactor(dist, maxDist)
if dist >= maxDist then return 0 end
return 1 - dist / maxDist
end
-- ============================================================
-- ANCHOR TETHER
-- Pull-out thresholds for dragging an anchored element, as multiples of the
-- snapDistance setting: within HOLD x snapDistance of the RESOLVED anchor
-- position the anchor is kept (the drop springs back), between HOLD x and
-- SNAP x the tether strains, and past SNAP x it snaps -- the element detaches
-- mid-drag. Multiples of the one reach setting the user already tunes, so a
-- bigger snap radius also means a longer tether.
-- ============================================================
S.TETHER_HOLD = 2 -- strain begins here: half the reach is warning band
S.TETHER_SNAP = 4
-- "held" | "strained" | "snapped" for a drag `dist` px out from the resolved
-- anchor position. snapDistance 0 turns frame snapping's reach off entirely,
-- so the tether is unbreakable there rather than hair-triggered.
function S.TetherState(dist, snapDistance)
if not snapDistance or snapDistance <= 0 then return "held" end
if dist <= snapDistance * S.TETHER_HOLD then return "held" end
if dist <= snapDistance * S.TETHER_SNAP then return "strained" end
return "snapped"
end
-- How hard the tether is being pulled: 0 up to the strain threshold, rising to
-- 1 at the snap point. Drives the strain colour lerp.
function S.TetherStrain(dist, snapDistance)
if not snapDistance or snapDistance <= 0 then return 0 end
local a, b = snapDistance * S.TETHER_HOLD, snapDistance * S.TETHER_SNAP
if dist <= a then return 0 end
if dist >= b then return 1 end
return (dist - a) / (b - a)
end