stdraw 0.7.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stdraw/visibility.py ADDED
@@ -0,0 +1,382 @@
1
+ # -*- coding: utf-8 -*-
2
+ """WS3 routing math layer: visibility-graph shortest straight-line paths + clearance-constrained minimum-curvature arcs.
3
+
4
+ Mathematical accounting (honest statement, consistent with the certificate wording):
5
+ 1. Straight-line obstacle avoidance = **Dijkstra shortest path on the visibility graph over inflated obstacle corners**
6
+ (exactly optimal on the graph, a classic computational-geometry theorem). Obstacles are box-grown by clearance c: the inflated
7
+ rectangle >= the rounded-corner Minkowski region (|p-rect| < c), hence "segment misses the inflated rectangle => Euclidean
8
+ clearance to the original rectangle >= c". Versus the true shortest path in the continuous space of rounded obstacles, each
9
+ hugged corner adds at most (2-pi/2)*c ~= 0.43c px -- recovered by the corner-cutting effect of arc smoothing.
10
+ 2. Every visibility edge is independently rechecked with the **exact segment-rectangle distance** (Liang-Barsky intersection
11
+ test + four-border segment-segment distances); clearance is an analytic guarantee, not a coincidence of discrete sampling.
12
+ 3. Arc smoothing = tangent-inscribed circular arcs at bends: G1 tangent continuity (no inflection), curvature bounded
13
+ throughout (<= 1/r_min). Radii shrink along a deterministic sequence; each arc rechecks clearance at every sampled point;
14
+ this module builds the graph and rechecks at the "target clearance c"; the caller passes c = CLEARANCE + 1.5, leaving
15
+ a >= +1.4px margin at delivery-coordinate recheck, so the >=16px hard gate is never grazed.
16
+ 4. Deterministic: no RNG. Node order, candidate order, radius sequence all fixed; same input gives byte-for-byte the same output.
17
+
18
+ This module is stdlib-only, with no networkx/ortools dependency.
19
+ """
20
+ import math
21
+ from heapq import heappush, heappop
22
+
23
+ VIS_EPS = 1e-6 # float tolerance for visibility tests
24
+ ARC_SAMPLES = 16 # sampled segments per circular arc (for geometry/crossing rechecks)
25
+ FILLET_R_MAX = 36.0 # smoothing-arc maximum radius px
26
+ FILLET_R_SHRINK = (1.0, 0.75, 0.55, 0.40, 0.28, 0.18, 0.10) # deterministic shrink sequence
27
+ FILLET_TAU_MAX = 0.45 # upper bound on tangent-segment length as a fraction of the adjacent segment length
28
+ DEFLECT_SKIP = 0.03 # bend deflection < ~1.7 degrees is treated as straight, no arc
29
+
30
+
31
+ # -- exact distance primitives -----------------------------------------
32
+ def _pt_seg_dist(px, py, a, b):
33
+ """Minimum distance from a point to a segment."""
34
+ vx, vy = b[0] - a[0], b[1] - a[1]
35
+ L2 = vx * vx + vy * vy
36
+ if L2 < VIS_EPS:
37
+ return math.hypot(px - a[0], py - a[1])
38
+ t = max(0.0, min(1.0, ((px - a[0]) * vx + (py - a[1]) * vy) / L2))
39
+ return math.hypot(px - (a[0] + t * vx), py - (a[1] + t * vy))
40
+
41
+
42
+ def seg_seg_dist(p1, p2, p3, p4):
43
+ """Distance between two segments: 0 if intersecting; otherwise the minimum of the four endpoint-to-other-segment distances."""
44
+ def orient(a, b, c):
45
+ v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
46
+ return 0 if abs(v) < VIS_EPS else (1 if v > 0 else -1)
47
+ d1, d2 = orient(p3, p4, p1), orient(p3, p4, p2)
48
+ d3, d4 = orient(p1, p2, p3), orient(p1, p2, p4)
49
+ if d1 != d2 and d3 != d4 and d1 != 0 and d3 != 0:
50
+ return 0.0
51
+ return min(_pt_seg_dist(p1[0], p1[1], p3, p4),
52
+ _pt_seg_dist(p2[0], p2[1], p3, p4),
53
+ _pt_seg_dist(p3[0], p3[1], p1, p2),
54
+ _pt_seg_dist(p4[0], p4[1], p1, p2))
55
+
56
+
57
+ def _seg_hits_rect(x1, y1, x2, y2, cx, cy, hw, hh):
58
+ """Liang-Barsky: whether a segment enters the rectangle with positive length (border-touching does not count). Same accounting as metrics.
59
+
60
+ Self-contained implementation (metrics -> routing -> this module would be a circular import, so no back-reference).
61
+ """
62
+ dx, dy = x2 - x1, y2 - y1
63
+ t0, t1 = 0.0, 1.0
64
+ for p, q in ((-dx, x1 - (cx - hw)), (dx, (cx + hw) - x1),
65
+ (-dy, y1 - (cy - hh)), (dy, (cy + hh) - y1)):
66
+ if abs(p) < VIS_EPS:
67
+ if q < 0:
68
+ return False
69
+ else:
70
+ r = q / p
71
+ if p < 0:
72
+ if r > t1:
73
+ return False
74
+ if r > t0:
75
+ t0 = r
76
+ else:
77
+ if r < t0:
78
+ return False
79
+ if r < t1:
80
+ t1 = r
81
+ return t1 - t0 > VIS_EPS
82
+
83
+
84
+ def seg_rect_distance(p1, p2, rect):
85
+ """Exact minimum distance from a segment to a rectangle (center + half-width/half-height): 0 if intersecting.
86
+
87
+ Intersection uses Liang-Barsky (positive-length entry counts, border-touching does not); when disjoint, take the minimum
88
+ segment-segment distance over the rectangle's four borders.
89
+ """
90
+ cx, cy, hw, hh = rect
91
+ if _seg_hits_rect(p1[0], p1[1], p2[0], p2[1], cx, cy, hw, hh):
92
+ return 0.0
93
+ corners = ((cx - hw, cy - hh), (cx + hw, cy - hh),
94
+ (cx + hw, cy + hh), (cx - hw, cy + hh))
95
+ return min(seg_seg_dist(p1, p2, corners[k], corners[(k + 1) % 4])
96
+ for k in range(4))
97
+
98
+
99
+ def pt_rect_distance(px, py, rect):
100
+ """Euclidean distance from a point to a rectangle (0 when inside)."""
101
+ cx, cy, hw, hh = rect
102
+ dx = max(abs(px - cx) - hw, 0.0)
103
+ dy = max(abs(py - cy) - hh, 0.0)
104
+ return math.hypot(dx, dy)
105
+
106
+
107
+ # -- visibility graph + Dijkstra ----------------------------------------
108
+ def _graph_nodes(src, dst, rects, c):
109
+ """Nodes = source/sink + the 4 corners of every inflated rectangle (inflated corners sit exactly c from the original rectangle)."""
110
+ nodes = [tuple(src), tuple(dst)]
111
+ for (cx, cy, hw, hh) in rects:
112
+ gx, gy = hw + c, hh + c
113
+ nodes.extend([(cx - gx, cy - gy), (cx + gx, cy - gy),
114
+ (cx + gx, cy + gy), (cx - gx, cy + gy)])
115
+ return nodes
116
+
117
+
118
+ def _visible(p1, p2, rects, c):
119
+ """Segment keeps clearance >= c - epsilon against all obstacles (analytic recheck, no sampling).
120
+
121
+ Performance (WS5): first a lower-bound quick filter via the "segment bbox x rectangle bbox" distance -- any point on the
122
+ segment is at a distance from the rectangle >= the bbox distance, so when the bbox distance >= c - epsilon the exact distance
123
+ is necessarily >= c - epsilon and the exact computation can be skipped (exactly equivalent, changes no test outcome).
124
+ """
125
+ t = c - VIS_EPS
126
+ t2 = t * t
127
+ x1l, x1h = (p1[0], p2[0]) if p1[0] <= p2[0] else (p2[0], p1[0])
128
+ y1l, y1h = (p1[1], p2[1]) if p1[1] <= p2[1] else (p2[1], p1[1])
129
+ for rect in rects:
130
+ cx, cy, hw, hh = rect
131
+ gx = cx - hw - x1h
132
+ g = x1l - cx - hw
133
+ if g > gx:
134
+ gx = g
135
+ if gx < 0.0:
136
+ gx = 0.0
137
+ if gx < t:
138
+ gy = cy - hh - y1h
139
+ g = y1l - cy - hh
140
+ if g > gy:
141
+ gy = g
142
+ if gy < 0.0:
143
+ gy = 0.0
144
+ # gx/gy already clamped to 0: hypot(gx,gy) = the bbox distance; only < t can possibly block the line
145
+ if gy < t and (gx == 0.0 or gy == 0.0 or gx * gx + gy * gy < t2):
146
+ if seg_rect_distance(p1, p2, rect) < t:
147
+ return False
148
+ return True
149
+
150
+
151
+ def _dijkstra(nodes, adj, src_idx, dst_idx):
152
+ """Deterministic Dijkstra: heap key (dist, node_idx); node_idx is unique -> fixed pop order."""
153
+ INF = float("inf")
154
+ dist = [INF] * len(nodes)
155
+ prev = [-1] * len(nodes)
156
+ dist[src_idx] = 0.0
157
+ heap = [(0.0, src_idx)]
158
+ while heap:
159
+ d, u = heappop(heap)
160
+ if d > dist[u] + VIS_EPS:
161
+ continue
162
+ if u == dst_idx:
163
+ break
164
+ # adj[u] generated in ascending node_idx order -> fixed relaxation order; ties not replaced (first come, first kept)
165
+ for v, w in adj[u]:
166
+ nd = d + w
167
+ if nd < dist[v] - VIS_EPS:
168
+ dist[v] = nd
169
+ prev[v] = u
170
+ heappush(heap, (nd, v))
171
+ if dist[dst_idx] >= INF:
172
+ return None
173
+ path, cur = [], dst_idx
174
+ while cur != -1:
175
+ path.append(nodes[cur])
176
+ cur = prev[cur]
177
+ path.reverse()
178
+ return path
179
+
180
+
181
+ def shortest_path(src, dst, rects, c):
182
+ """Shortest path on the visibility graph over inflated obstacle corners. Returns None when unreachable.
183
+
184
+ Returns the polyline [src, corner*, ..., dst] (exactly optimal on the graph; optimality bounds in the module docstring).
185
+ """
186
+ if not rects:
187
+ return [tuple(src), tuple(dst)]
188
+ nodes = _graph_nodes(src, dst, rects, c)
189
+ n = len(nodes)
190
+ # Adjacency list: O(n^2) visibility rechecks, generated in ascending (i,j) order -> deterministic
191
+ adj = [[] for _ in range(n)]
192
+ for i in range(n):
193
+ for j in range(i + 1, n):
194
+ if _visible(nodes[i], nodes[j], rects, c):
195
+ w = math.hypot(nodes[i][0] - nodes[j][0], nodes[i][1] - nodes[j][1])
196
+ adj[i].append((j, w))
197
+ adj[j].append((i, w))
198
+ return _dijkstra(nodes, adj, 0, 1)
199
+
200
+
201
+ def _shortcut(path, rects, c, rounds=3):
202
+ """Visible-chord shortcuts (string pulling): try skipping intermediate points in order, stop on convergence. Deterministic."""
203
+ pts = list(path)
204
+ for _ in range(rounds):
205
+ changed = False
206
+ i = 1
207
+ while i < len(pts) - 1:
208
+ if _visible(pts[i - 1], pts[i + 1], rects, c):
209
+ del pts[i]
210
+ changed = True
211
+ else:
212
+ i += 1
213
+ if not changed:
214
+ break
215
+ return pts
216
+
217
+
218
+ # -- arc smoothing (clearance-constrained minimum curvature) -------------
219
+ def _fillet(a, q, b, rects, c):
220
+ """Tangent arc at bend q. Returns None (no arc wanted / no arc possible) or an arc-parameter dict.
221
+
222
+ G1: the arc is tangent-continuous with the adjacent segments at ta/tb; curvature = 1/r is bounded.
223
+ Radii shrink deterministically; clearance is rechecked at every sampled point as >= c - 0.005 (chord height on the
224
+ <=0.05px scale; the delivery-coordinate 16px hard gate keeps the c-16=1.5px inflation margin as a fallback).
225
+ Geometric bound (honest): the tangent arc at a convex corner dips r*(1/cos(theta/2)-1) toward the obstacle side;
226
+ shallow corners admit large arcs while right angles, constrained by the 1.5px margin, admit only a tiny fillet (r <= ~3.6).
227
+ """
228
+ ux, uy = q[0] - a[0], q[1] - a[1]
229
+ wx, wy = b[0] - q[0], b[1] - q[1]
230
+ lu, lw = math.hypot(ux, uy), math.hypot(wx, wy)
231
+ if lu < VIS_EPS or lw < VIS_EPS:
232
+ return None
233
+ ux, uy, wx, wy = ux / lu, uy / lu, wx / lw, wy / lw
234
+ # Deflection angle theta = angle between u and w (theta=0 straight continuation; theta=pi U-turn)
235
+ theta = math.acos(max(-1.0, min(1.0, ux * wx + uy * wy)))
236
+ if theta < DEFLECT_SKIP or theta > math.pi - DEFLECT_SKIP:
237
+ return None
238
+ cos_half = math.cos(theta / 2)
239
+ tan_half = math.tan(theta / 2)
240
+ # Tangent-segment length t = r*tan(theta/2), center distance d = r/cos(theta/2) (the center sits inside the bend wedge,
241
+ # tangent to both adjacent segment lines). Radius upper bound = the smaller of the tangent-segment clamp and the absolute cap.
242
+ tau_max = FILLET_TAU_MAX * min(lu, lw)
243
+ r0 = min(FILLET_R_MAX, tau_max / tan_half)
244
+ for f in FILLET_R_SHRINK:
245
+ r = r0 * f
246
+ if r < 1.5:
247
+ break
248
+ t = min(tau_max, r * tan_half)
249
+ r = t / tan_half
250
+ if r < 1.5:
251
+ continue
252
+ # Interior angle-bisector direction (the sum of the unit vectors -u and w)
253
+ bx, by = -ux + wx, -uy + wy
254
+ lb = math.hypot(bx, by)
255
+ if lb < VIS_EPS:
256
+ continue
257
+ bx, by = bx / lb, by / lb
258
+ d = r / cos_half
259
+ center = (q[0] + bx * d, q[1] + by * d)
260
+ ta = (q[0] - ux * t, q[1] - uy * t)
261
+ tb = (q[0] + wx * t, q[1] + wy * t)
262
+ samples = _arc_samples(center, r, ta, tb)
263
+ # Defense in depth: tangent-perpendicularity self-check (the ta radius perpendicular to the incoming segment, the tb radius to the outgoing segment -- the analytic G1 condition)
264
+ rada = (ta[0] - center[0], ta[1] - center[1])
265
+ radb = (tb[0] - center[0], tb[1] - center[1])
266
+ if (abs(rada[0] * ux + rada[1] * uy) > 1e-6 * r or
267
+ abs(radb[0] * wx + radb[1] * wy) > 1e-6 * r):
268
+ continue
269
+ if all(pt_rect_distance(px, py, rect) >= c - 0.005
270
+ for (px, py) in samples for rect in rects):
271
+ cross = ((ta[0] - center[0]) * (tb[1] - center[1]) -
272
+ (ta[1] - center[1]) * (tb[0] - center[0]))
273
+ return {"ta": list(ta), "tb": list(tb), "center": list(center),
274
+ "r": round(r, 2), "sweep": 1 if cross > 0 else 0}
275
+ return None
276
+
277
+
278
+ def _arc_samples(center, r, ta, tb, n=ARC_SAMPLES):
279
+ """n+1 sampled points of the arc ta->tb (along the short arc, angular interpolation)."""
280
+ a0 = math.atan2(ta[1] - center[1], ta[0] - center[0])
281
+ a1 = math.atan2(tb[1] - center[1], tb[0] - center[0])
282
+ da = a1 - a0
283
+ while da > math.pi:
284
+ da -= 2 * math.pi
285
+ while da < -math.pi:
286
+ da += 2 * math.pi
287
+ return [(center[0] + r * math.cos(a0 + da * k / n),
288
+ center[1] + r * math.sin(a0 + da * k / n)) for k in range(n + 1)]
289
+
290
+
291
+ def smooth_corners(pts, rects, c):
292
+ """Arc smoothing of bends. Returns (fillets, corner_pts).
293
+
294
+ fillets[i] = {"i": index of the corner in corner_pts, ta/tb/center/r/sweep}.
295
+ Corners that cannot take an arc stay sharp (clearance is still >= c by construction of the graph).
296
+ """
297
+ fillets = []
298
+ for i in range(1, len(pts) - 1):
299
+ f = _fillet(pts[i - 1], pts[i], pts[i + 1], rects, c)
300
+ if f:
301
+ f["i"] = i
302
+ fillets.append(f)
303
+ return fillets, list(pts)
304
+
305
+
306
+ def dense_geometry(corner_pts, fillets, arc_n=ARC_SAMPLES):
307
+ """Polyline + arcs -> densely sampled point sequence (the geometric ground truth for clearance/crossing rechecks)."""
308
+ fmap = {f["i"]: f for f in fillets}
309
+ out = [tuple(corner_pts[0])]
310
+ for i in range(1, len(corner_pts)):
311
+ q = tuple(corner_pts[i])
312
+ f = fmap.get(i)
313
+ if f:
314
+ out.extend(tuple(p) for p in _arc_samples(f["center"], f["r"],
315
+ f["ta"], f["tb"], arc_n)[1:])
316
+ else:
317
+ out.append(q)
318
+ return out
319
+
320
+
321
+ def route_svg_d(corner_pts, fillets, nd=1):
322
+ """Delivery coordinates -> SVG path d string: straight segments L + arc segments A (exact rendering, not a polyline approximation)."""
323
+ fmap = {f["i"]: f for f in fillets}
324
+ r6 = lambda v: round(float(v), nd)
325
+ parts = [f"M {r6(corner_pts[0][0])} {r6(corner_pts[0][1])}"]
326
+ for i in range(1, len(corner_pts)):
327
+ q = corner_pts[i]
328
+ f = fmap.get(i)
329
+ if f:
330
+ # t_cf6d6978 #15: a corner with a fillet is replaced by ta->arc->tb and must not be drawn back to
331
+ # the corner itself (an unconditional L q once caused tb->q->ta_next zigzag glitches).
332
+ parts.append(f"L {r6(f['ta'][0])} {r6(f['ta'][1])}")
333
+ parts.append(f"A {r6(f['r'])} {r6(f['r'])} 0 0 {f['sweep']} "
334
+ f"{r6(f['tb'][0])} {r6(f['tb'][1])}")
335
+ else:
336
+ parts.append(f"L {r6(q[0])} {r6(q[1])}")
337
+ return " ".join(parts)
338
+
339
+
340
+ def plan_route(src, dst, rects, c):
341
+ """High-level entry: the obstacle-avoiding route from src to dst (with a deterministic cache -- a pure function, same input same output).
342
+
343
+ t_7fe64f08 cache hits return a **deep copy**: callers reuse the dense list in place as delivery
344
+ points (midband_reanchor/fan_spread mutate anchors in place); returning the cached object directly
345
+ would poison the global cache with those mutations and break cross-run determinism (measured: vg route drift on n20).
346
+ """
347
+ key = ((round(float(src[0]), 3), round(float(src[1]), 3)),
348
+ (round(float(dst[0]), 3), round(float(dst[1]), 3)),
349
+ tuple((round(r[0], 3), round(r[1], 3), round(r[2], 3), round(r[3], 3))
350
+ for r in rects), round(float(c), 3))
351
+ hit = _ROUTE_CACHE.get(key)
352
+ if hit is not None:
353
+ return {"corner_pts": [list(p) for p in hit["corner_pts"]],
354
+ "fillets": [dict(f) for f in hit["fillets"]],
355
+ "dense": [tuple(p) for p in hit["dense"]],
356
+ "path_d": hit["path_d"]}
357
+ result = _plan_route(src, dst, rects, c)
358
+ if len(_ROUTE_CACHE) > 8192:
359
+ _ROUTE_CACHE.clear()
360
+ _ROUTE_CACHE[key] = result
361
+ if result is None:
362
+ return None
363
+ return {"corner_pts": [list(p) for p in result["corner_pts"]],
364
+ "fillets": [dict(f) for f in result["fillets"]],
365
+ "dense": [tuple(p) for p in result["dense"]],
366
+ "path_d": result["path_d"]}
367
+
368
+
369
+ _ROUTE_CACHE = {}
370
+
371
+
372
+ def _plan_route(src, dst, rects, c):
373
+ """plan_route implementation (no cache). Returns a dict, or None when unreachable."""
374
+ src, dst = tuple(src), tuple(dst)
375
+ path = shortest_path(src, dst, rects, c)
376
+ if path is None:
377
+ return None
378
+ path = _shortcut(path, rects, c)
379
+ fillets, corners = smooth_corners(path, rects, c)
380
+ dense = dense_geometry(corners, fillets)
381
+ return {"corner_pts": corners, "fillets": fillets,
382
+ "dense": dense, "path_d": route_svg_d(corners, fillets)}