@tscircuit/rectdiff 0.0.1

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.
Files changed (40) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/bun-formatcheck.yml +26 -0
  3. package/.github/workflows/bun-pver-release.yml +71 -0
  4. package/.github/workflows/bun-test.yml +31 -0
  5. package/.github/workflows/bun-typecheck.yml +26 -0
  6. package/CLAUDE.md +23 -0
  7. package/README.md +5 -0
  8. package/biome.json +93 -0
  9. package/bun.lock +29 -0
  10. package/bunfig.toml +5 -0
  11. package/components/SolverDebugger3d.tsx +833 -0
  12. package/cosmos.config.json +6 -0
  13. package/cosmos.decorator.tsx +21 -0
  14. package/dist/index.d.ts +111 -0
  15. package/dist/index.js +921 -0
  16. package/experiments/rect-fill-2d.tsx +983 -0
  17. package/experiments/rect3d_visualizer.html +640 -0
  18. package/global.d.ts +4 -0
  19. package/index.html +12 -0
  20. package/lib/index.ts +1 -0
  21. package/lib/solvers/RectDiffSolver.ts +158 -0
  22. package/lib/solvers/rectdiff/candidates.ts +397 -0
  23. package/lib/solvers/rectdiff/engine.ts +355 -0
  24. package/lib/solvers/rectdiff/geometry.ts +284 -0
  25. package/lib/solvers/rectdiff/layers.ts +48 -0
  26. package/lib/solvers/rectdiff/rectsToMeshNodes.ts +22 -0
  27. package/lib/solvers/rectdiff/types.ts +63 -0
  28. package/lib/types/capacity-mesh-types.ts +33 -0
  29. package/lib/types/srj-types.ts +37 -0
  30. package/package.json +33 -0
  31. package/pages/example01.page.tsx +11 -0
  32. package/test-assets/example01.json +933 -0
  33. package/tests/__snapshots__/svg.snap.svg +3 -0
  34. package/tests/examples/__snapshots__/example01.snap.svg +121 -0
  35. package/tests/examples/example01.test.tsx +65 -0
  36. package/tests/fixtures/preload.ts +1 -0
  37. package/tests/incremental-solver.test.ts +100 -0
  38. package/tests/rect-diff-solver.test.ts +154 -0
  39. package/tests/svg.test.ts +12 -0
  40. package/tsconfig.json +30 -0
@@ -0,0 +1,158 @@
1
+ // lib/solvers/RectDiffSolver.ts
2
+ import { BaseSolver } from "@tscircuit/solver-utils"
3
+ import type { SimpleRouteJson } from "../types/srj-types"
4
+ import type { GraphicsObject } from "graphics-debug"
5
+ import type { CapacityMeshNode } from "../types/capacity-mesh-types"
6
+
7
+ import type { GridFill3DOptions, RectDiffState } from "./rectdiff/types"
8
+ import { initState, stepGrid, stepExpansion, finalizeRects, computeProgress } from "./rectdiff/engine"
9
+ import { rectsToMeshNodes } from "./rectdiff/rectsToMeshNodes"
10
+
11
+ // A streaming, one-step-per-iteration solver.
12
+ // Tests that call `solver.solve()` still work because BaseSolver.solve()
13
+ // loops until this.solved flips true.
14
+
15
+ export class RectDiffSolver extends BaseSolver {
16
+ private srj: SimpleRouteJson
17
+ private mode: "grid" | "exact"
18
+ private gridOptions: Partial<GridFill3DOptions>
19
+ private state!: RectDiffState
20
+ private _meshNodes: CapacityMeshNode[] = []
21
+
22
+ constructor(opts: {
23
+ simpleRouteJson: SimpleRouteJson
24
+ mode?: "grid" | "exact"
25
+ gridOptions?: Partial<GridFill3DOptions>
26
+ }) {
27
+ super()
28
+ this.srj = opts.simpleRouteJson
29
+ this.mode = opts.mode ?? "grid"
30
+ this.gridOptions = opts.gridOptions ?? {}
31
+ }
32
+
33
+ override _setup() {
34
+ // For now "exact" mode falls back to grid; keep switch if you add exact later.
35
+ this.state = initState(this.srj, this.gridOptions)
36
+ this.stats = {
37
+ phase: this.state.phase,
38
+ gridIndex: this.state.gridIndex,
39
+ }
40
+ }
41
+
42
+ /** IMPORTANT: exactly ONE small step per call */
43
+ override _step() {
44
+ if (this.state.phase === "GRID") {
45
+ stepGrid(this.state)
46
+ } else if (this.state.phase === "EXPANSION") {
47
+ stepExpansion(this.state)
48
+ } else if (this.state.phase === "DONE") {
49
+ // Finalize once
50
+ if (!this.solved) {
51
+ const rects = finalizeRects(this.state)
52
+ this._meshNodes = rectsToMeshNodes(rects)
53
+ this.solved = true
54
+ }
55
+ return
56
+ }
57
+
58
+ // Lightweight stats for debugger
59
+ this.stats.phase = this.state.phase
60
+ this.stats.gridIndex = this.state.gridIndex
61
+ this.stats.placed = this.state.placed.length
62
+ }
63
+
64
+ // Let BaseSolver update this.progress automatically if present.
65
+ computeProgress(): number {
66
+ return computeProgress(this.state)
67
+ }
68
+
69
+ override getOutput(): { meshNodes: CapacityMeshNode[] } {
70
+ return { meshNodes: this._meshNodes }
71
+ }
72
+
73
+ // Helper to get color based on z layer
74
+ private getColorForZLayer(zLayers: number[]): { fill: string; stroke: string } {
75
+ const minZ = Math.min(...zLayers)
76
+ const colors = [
77
+ { fill: "#dbeafe", stroke: "#3b82f6" }, // blue (z=0)
78
+ { fill: "#fef3c7", stroke: "#f59e0b" }, // amber (z=1)
79
+ { fill: "#d1fae5", stroke: "#10b981" }, // green (z=2)
80
+ { fill: "#e9d5ff", stroke: "#a855f7" }, // purple (z=3)
81
+ { fill: "#fed7aa", stroke: "#f97316" }, // orange (z=4)
82
+ { fill: "#fecaca", stroke: "#ef4444" }, // red (z=5)
83
+ ]
84
+ return colors[minZ % colors.length]!
85
+ }
86
+
87
+ // Streaming visualization: board + obstacles + current placements.
88
+ override visualize(): GraphicsObject {
89
+ const rects: NonNullable<GraphicsObject["rects"]> = []
90
+ const points: NonNullable<GraphicsObject["points"]> = []
91
+
92
+ // board
93
+ rects.push({
94
+ center: {
95
+ x: (this.srj.bounds.minX + this.srj.bounds.maxX) / 2,
96
+ y: (this.srj.bounds.minY + this.srj.bounds.maxY) / 2,
97
+ },
98
+ width: this.srj.bounds.maxX - this.srj.bounds.minX,
99
+ height: this.srj.bounds.maxY - this.srj.bounds.minY,
100
+ fill: "none",
101
+ stroke: "#111827",
102
+ label: "board",
103
+ })
104
+
105
+ // obstacles (rect & oval as bounding boxes)
106
+ for (const ob of this.srj.obstacles ?? []) {
107
+ if (ob.type === "rect" || ob.type === "oval") {
108
+ rects.push({
109
+ center: { x: ob.center.x, y: ob.center.y },
110
+ width: ob.width,
111
+ height: ob.height,
112
+ fill: "#fee2e2",
113
+ stroke: "#ef4444",
114
+ layer: "obstacle",
115
+ label: "obstacle",
116
+ })
117
+ }
118
+ }
119
+
120
+ // candidate positions (where expansion started from)
121
+ if (this.state?.candidates?.length) {
122
+ for (const cand of this.state.candidates) {
123
+ points.push({
124
+ x: cand.x,
125
+ y: cand.y,
126
+ fill: "#9333ea",
127
+ stroke: "#6b21a8",
128
+ label: `z:${cand.z}`,
129
+ } as any)
130
+ }
131
+ }
132
+
133
+ // current placements (streaming) if not yet solved
134
+ if (this.state?.placed?.length) {
135
+ for (const p of this.state.placed) {
136
+ const colors = this.getColorForZLayer(p.zLayers)
137
+ rects.push({
138
+ center: { x: p.rect.x + p.rect.width / 2, y: p.rect.y + p.rect.height / 2 },
139
+ width: p.rect.width,
140
+ height: p.rect.height,
141
+ fill: colors.fill,
142
+ stroke: colors.stroke,
143
+ label: `free\nz:${p.zLayers.join(",")}`,
144
+ })
145
+ }
146
+ }
147
+
148
+ return {
149
+ title: "RectDiff (incremental)",
150
+ coordinateSystem: "cartesian",
151
+ rects,
152
+ points,
153
+ }
154
+ }
155
+ }
156
+
157
+ // Re-export types for convenience
158
+ export type { GridFill3DOptions } from "./rectdiff/types"
@@ -0,0 +1,397 @@
1
+ // lib/solvers/rectdiff/candidates.ts
2
+ import type { Candidate3D, XYRect } from "./types"
3
+ import { EPS, clamp, containsPoint, distancePointToRectEdges } from "./geometry"
4
+
5
+ function isFullyOccupiedAllLayers(
6
+ x: number,
7
+ y: number,
8
+ layerCount: number,
9
+ obstaclesByLayer: XYRect[][],
10
+ placedByLayer: XYRect[][],
11
+ ): boolean {
12
+ for (let z = 0; z < layerCount; z++) {
13
+ const obs = obstaclesByLayer[z] ?? []
14
+ const placed = placedByLayer[z] ?? []
15
+ const occ =
16
+ obs.some((b) => containsPoint(b, x, y)) ||
17
+ placed.some((b) => containsPoint(b, x, y))
18
+ if (!occ) return false
19
+ }
20
+ return true
21
+ }
22
+
23
+ export function computeCandidates3D(
24
+ bounds: XYRect,
25
+ gridSize: number,
26
+ layerCount: number,
27
+ obstaclesByLayer: XYRect[][],
28
+ placedByLayer: XYRect[][], // all current nodes (soft + hard)
29
+ hardPlacedByLayer: XYRect[][], // only full-stack nodes, treated as hard
30
+ ): Candidate3D[] {
31
+ const out = new Map<string, Candidate3D>() // key by (x,y)
32
+
33
+ for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) {
34
+ for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) {
35
+ // Skip outermost row/col (stable with prior behavior)
36
+ if (
37
+ Math.abs(x - bounds.x) < EPS ||
38
+ Math.abs(y - bounds.y) < EPS ||
39
+ x > bounds.x + bounds.width - gridSize - EPS ||
40
+ y > bounds.y + bounds.height - gridSize - EPS
41
+ ) {
42
+ continue
43
+ }
44
+
45
+ // New rule: Only drop if EVERY layer is occupied (by obstacle or node)
46
+ if (isFullyOccupiedAllLayers(x, y, layerCount, obstaclesByLayer, placedByLayer)) continue
47
+
48
+ // Find the best (longest) free contiguous Z span at (x,y) ignoring soft nodes.
49
+ let bestSpan: number[] = []
50
+ let bestZ = 0
51
+ for (let z = 0; z < layerCount; z++) {
52
+ const s = longestFreeSpanAroundZ(
53
+ x,
54
+ y,
55
+ z,
56
+ layerCount,
57
+ 1,
58
+ undefined, // no cap here
59
+ obstaclesByLayer,
60
+ hardPlacedByLayer, // IMPORTANT: ignore soft nodes
61
+ )
62
+ if (s.length > bestSpan.length) {
63
+ bestSpan = s
64
+ bestZ = z
65
+ }
66
+ }
67
+ const anchorZ = bestSpan.length
68
+ ? bestSpan[Math.floor(bestSpan.length / 2)]!
69
+ : bestZ
70
+
71
+ // Distance heuristic against hard blockers only (obstacles + full-stack)
72
+ const hardAtZ = [
73
+ ...(obstaclesByLayer[anchorZ] ?? []),
74
+ ...(hardPlacedByLayer[anchorZ] ?? []),
75
+ ]
76
+ const d = Math.min(
77
+ distancePointToRectEdges(x, y, bounds),
78
+ ...(hardAtZ.length
79
+ ? hardAtZ.map((b) => distancePointToRectEdges(x, y, b))
80
+ : [Infinity]),
81
+ )
82
+
83
+ const k = `${x.toFixed(6)}|${y.toFixed(6)}`
84
+ const cand: Candidate3D = {
85
+ x,
86
+ y,
87
+ z: anchorZ,
88
+ distance: d,
89
+ zSpanLen: bestSpan.length,
90
+ }
91
+ const prev = out.get(k)
92
+ if (
93
+ !prev ||
94
+ cand.zSpanLen! > (prev.zSpanLen ?? 0) ||
95
+ (cand.zSpanLen === prev.zSpanLen && cand.distance > prev.distance)
96
+ ) {
97
+ out.set(k, cand)
98
+ }
99
+ }
100
+ }
101
+
102
+ const arr = Array.from(out.values())
103
+ arr.sort((a, b) => (b.zSpanLen! - a.zSpanLen!) || (b.distance - a.distance))
104
+ return arr
105
+ }
106
+
107
+ /** Longest contiguous free span around z (optionally capped) */
108
+ export function longestFreeSpanAroundZ(
109
+ x: number,
110
+ y: number,
111
+ z: number,
112
+ layerCount: number,
113
+ minSpan: number,
114
+ maxSpan: number | undefined,
115
+ obstaclesByLayer: XYRect[][],
116
+ placedByLayer: XYRect[][],
117
+ ): number[] {
118
+ const isFreeAt = (layer: number) => {
119
+ const blockers = [...(obstaclesByLayer[layer] ?? []), ...(placedByLayer[layer] ?? [])]
120
+ return !blockers.some((b) => containsPoint(b, x, y))
121
+ }
122
+ let lo = z
123
+ let hi = z
124
+ while (lo - 1 >= 0 && isFreeAt(lo - 1)) lo--
125
+ while (hi + 1 < layerCount && isFreeAt(hi + 1)) hi++
126
+
127
+ if (typeof maxSpan === "number") {
128
+ const target = clamp(maxSpan, 1, layerCount)
129
+ // trim symmetrically (keeping z inside)
130
+ while (hi - lo + 1 > target) {
131
+ if (z - lo > hi - z) lo++
132
+ else hi--
133
+ }
134
+ }
135
+
136
+ const res: number[] = []
137
+ for (let i = lo; i <= hi; i++) res.push(i)
138
+ return res.length >= minSpan ? res : []
139
+ }
140
+
141
+ export function computeDefaultGridSizes(bounds: XYRect): number[] {
142
+ const ref = Math.max(bounds.width, bounds.height)
143
+ return [ref / 8, ref / 16, ref / 32]
144
+ }
145
+
146
+ /** Compute exact uncovered segments along a 1D line given a list of covering intervals */
147
+ function computeUncoveredSegments(
148
+ lineStart: number,
149
+ lineEnd: number,
150
+ coveringIntervals: Array<{ start: number; end: number }>,
151
+ minSegmentLength: number,
152
+ ): Array<{ start: number; end: number; center: number }> {
153
+ if (coveringIntervals.length === 0) {
154
+ const center = (lineStart + lineEnd) / 2
155
+ return [{ start: lineStart, end: lineEnd, center }]
156
+ }
157
+
158
+ // Sort intervals by start position
159
+ const sorted = [...coveringIntervals].sort((a, b) => a.start - b.start)
160
+
161
+ // Merge overlapping intervals
162
+ const merged: Array<{ start: number; end: number }> = []
163
+ let current = { ...sorted[0]! }
164
+
165
+ for (let i = 1; i < sorted.length; i++) {
166
+ const interval = sorted[i]!
167
+ if (interval.start <= current.end + EPS) {
168
+ // Overlapping or adjacent - merge
169
+ current.end = Math.max(current.end, interval.end)
170
+ } else {
171
+ // No overlap - save current and start new
172
+ merged.push(current)
173
+ current = { ...interval }
174
+ }
175
+ }
176
+ merged.push(current)
177
+
178
+ // Find gaps between merged intervals
179
+ const uncovered: Array<{ start: number; end: number; center: number }> = []
180
+
181
+ // Check gap before first interval
182
+ if (merged[0]!.start > lineStart + EPS) {
183
+ const start = lineStart
184
+ const end = merged[0]!.start
185
+ if (end - start >= minSegmentLength) {
186
+ uncovered.push({ start, end, center: (start + end) / 2 })
187
+ }
188
+ }
189
+
190
+ // Check gaps between intervals
191
+ for (let i = 0; i < merged.length - 1; i++) {
192
+ const start = merged[i]!.end
193
+ const end = merged[i + 1]!.start
194
+ if (end - start >= minSegmentLength) {
195
+ uncovered.push({ start, end, center: (start + end) / 2 })
196
+ }
197
+ }
198
+
199
+ // Check gap after last interval
200
+ if (merged[merged.length - 1]!.end < lineEnd - EPS) {
201
+ const start = merged[merged.length - 1]!.end
202
+ const end = lineEnd
203
+ if (end - start >= minSegmentLength) {
204
+ uncovered.push({ start, end, center: (start + end) / 2 })
205
+ }
206
+ }
207
+
208
+ return uncovered
209
+ }
210
+
211
+ /** Exact edge analysis: find uncovered segments along board edges and blocker edges */
212
+ export function computeEdgeCandidates3D(
213
+ bounds: XYRect,
214
+ minSize: number,
215
+ layerCount: number,
216
+ obstaclesByLayer: XYRect[][],
217
+ placedByLayer: XYRect[][], // all nodes
218
+ hardPlacedByLayer: XYRect[][], // full-stack nodes
219
+ ): Candidate3D[] {
220
+ const out: Candidate3D[] = []
221
+ // Use small inset from edges for placement
222
+ const δ = Math.max(minSize * 0.15, EPS * 3)
223
+ const dedup = new Set<string>()
224
+ const key = (x: number, y: number, z: number) => `${z}|${x.toFixed(6)}|${y.toFixed(6)}`
225
+
226
+ function fullyOcc(x: number, y: number) {
227
+ return isFullyOccupiedAllLayers(x, y, layerCount, obstaclesByLayer, placedByLayer)
228
+ }
229
+
230
+ function pushIfFree(x: number, y: number, z: number) {
231
+ if (
232
+ x < bounds.x + EPS || y < bounds.y + EPS ||
233
+ x > bounds.x + bounds.width - EPS || y > bounds.y + bounds.height - EPS
234
+ ) return
235
+ if (fullyOcc(x, y)) return // new rule: only drop if truly impossible
236
+
237
+ // Distance uses obstacles + hard nodes (soft nodes ignored for ranking)
238
+ const hard = [
239
+ ...(obstaclesByLayer[z] ?? []),
240
+ ...(hardPlacedByLayer[z] ?? []),
241
+ ]
242
+ const d = Math.min(
243
+ distancePointToRectEdges(x, y, bounds),
244
+ ...(hard.length ? hard.map((b) => distancePointToRectEdges(x, y, b)) : [Infinity]),
245
+ )
246
+
247
+ const k = key(x, y, z)
248
+ if (dedup.has(k)) return
249
+ dedup.add(k)
250
+
251
+ // Approximate z-span strength at this z (ignoring soft nodes)
252
+ const span = longestFreeSpanAroundZ(x, y, z, layerCount, 1, undefined, obstaclesByLayer, hardPlacedByLayer)
253
+ out.push({ x, y, z, distance: d, zSpanLen: span.length, isEdgeSeed: true })
254
+ }
255
+
256
+ for (let z = 0; z < layerCount; z++) {
257
+ const blockers = [...(obstaclesByLayer[z] ?? []), ...(hardPlacedByLayer[z] ?? [])]
258
+
259
+ // 1) Board edges — find exact uncovered segments along each edge
260
+
261
+ // First, check corners explicitly
262
+ const corners = [
263
+ { x: bounds.x + δ, y: bounds.y + δ }, // top-left
264
+ { x: bounds.x + bounds.width - δ, y: bounds.y + δ }, // top-right
265
+ { x: bounds.x + δ, y: bounds.y + bounds.height - δ }, // bottom-left
266
+ { x: bounds.x + bounds.width - δ, y: bounds.y + bounds.height - δ }, // bottom-right
267
+ ]
268
+ for (const corner of corners) {
269
+ pushIfFree(corner.x, corner.y, z)
270
+ }
271
+
272
+ // Top edge (y = bounds.y + δ)
273
+ const topY = bounds.y + δ
274
+ const topCovering = blockers
275
+ .filter(b => b.y <= topY && b.y + b.height >= topY)
276
+ .map(b => ({ start: Math.max(bounds.x, b.x), end: Math.min(bounds.x + bounds.width, b.x + b.width) }))
277
+ // Find uncovered segments that are large enough to potentially fill
278
+ const topUncovered = computeUncoveredSegments(bounds.x + δ, bounds.x + bounds.width - δ, topCovering, minSize * 0.5)
279
+ for (const seg of topUncovered) {
280
+ const segLen = seg.end - seg.start
281
+ if (segLen >= minSize) {
282
+ // Seed center and a few strategic points
283
+ pushIfFree(seg.center, topY, z)
284
+ if (segLen > minSize * 1.5) {
285
+ pushIfFree(seg.start + minSize * 0.4, topY, z)
286
+ pushIfFree(seg.end - minSize * 0.4, topY, z)
287
+ }
288
+ }
289
+ }
290
+
291
+ // Bottom edge (y = bounds.y + bounds.height - δ)
292
+ const bottomY = bounds.y + bounds.height - δ
293
+ const bottomCovering = blockers
294
+ .filter(b => b.y <= bottomY && b.y + b.height >= bottomY)
295
+ .map(b => ({ start: Math.max(bounds.x, b.x), end: Math.min(bounds.x + bounds.width, b.x + b.width) }))
296
+ const bottomUncovered = computeUncoveredSegments(bounds.x + δ, bounds.x + bounds.width - δ, bottomCovering, minSize * 0.5)
297
+ for (const seg of bottomUncovered) {
298
+ const segLen = seg.end - seg.start
299
+ if (segLen >= minSize) {
300
+ pushIfFree(seg.center, bottomY, z)
301
+ if (segLen > minSize * 1.5) {
302
+ pushIfFree(seg.start + minSize * 0.4, bottomY, z)
303
+ pushIfFree(seg.end - minSize * 0.4, bottomY, z)
304
+ }
305
+ }
306
+ }
307
+
308
+ // Left edge (x = bounds.x + δ)
309
+ const leftX = bounds.x + δ
310
+ const leftCovering = blockers
311
+ .filter(b => b.x <= leftX && b.x + b.width >= leftX)
312
+ .map(b => ({ start: Math.max(bounds.y, b.y), end: Math.min(bounds.y + bounds.height, b.y + b.height) }))
313
+ const leftUncovered = computeUncoveredSegments(bounds.y + δ, bounds.y + bounds.height - δ, leftCovering, minSize * 0.5)
314
+ for (const seg of leftUncovered) {
315
+ const segLen = seg.end - seg.start
316
+ if (segLen >= minSize) {
317
+ pushIfFree(leftX, seg.center, z)
318
+ if (segLen > minSize * 1.5) {
319
+ pushIfFree(leftX, seg.start + minSize * 0.4, z)
320
+ pushIfFree(leftX, seg.end - minSize * 0.4, z)
321
+ }
322
+ }
323
+ }
324
+
325
+ // Right edge (x = bounds.x + bounds.width - δ)
326
+ const rightX = bounds.x + bounds.width - δ
327
+ const rightCovering = blockers
328
+ .filter(b => b.x <= rightX && b.x + b.width >= rightX)
329
+ .map(b => ({ start: Math.max(bounds.y, b.y), end: Math.min(bounds.y + bounds.height, b.y + b.height) }))
330
+ const rightUncovered = computeUncoveredSegments(bounds.y + δ, bounds.y + bounds.height - δ, rightCovering, minSize * 0.5)
331
+ for (const seg of rightUncovered) {
332
+ const segLen = seg.end - seg.start
333
+ if (segLen >= minSize) {
334
+ pushIfFree(rightX, seg.center, z)
335
+ if (segLen > minSize * 1.5) {
336
+ pushIfFree(rightX, seg.start + minSize * 0.4, z)
337
+ pushIfFree(rightX, seg.end - minSize * 0.4, z)
338
+ }
339
+ }
340
+ }
341
+
342
+ // 2) Around every obstacle and placed rect edge — find exact uncovered segments
343
+ for (const b of blockers) {
344
+ // Left edge of blocker (x = b.x - δ)
345
+ const obLeftX = b.x - δ
346
+ if (obLeftX > bounds.x + EPS && obLeftX < bounds.x + bounds.width - EPS) {
347
+ const obLeftCovering = blockers
348
+ .filter(bl => bl !== b && bl.x <= obLeftX && bl.x + bl.width >= obLeftX)
349
+ .map(bl => ({ start: Math.max(b.y, bl.y), end: Math.min(b.y + b.height, bl.y + bl.height) }))
350
+ const obLeftUncovered = computeUncoveredSegments(b.y, b.y + b.height, obLeftCovering, minSize * 0.5)
351
+ for (const seg of obLeftUncovered) {
352
+ pushIfFree(obLeftX, seg.center, z)
353
+ }
354
+ }
355
+
356
+ // Right edge of blocker (x = b.x + b.width + δ)
357
+ const obRightX = b.x + b.width + δ
358
+ if (obRightX > bounds.x + EPS && obRightX < bounds.x + bounds.width - EPS) {
359
+ const obRightCovering = blockers
360
+ .filter(bl => bl !== b && bl.x <= obRightX && bl.x + bl.width >= obRightX)
361
+ .map(bl => ({ start: Math.max(b.y, bl.y), end: Math.min(b.y + b.height, bl.y + bl.height) }))
362
+ const obRightUncovered = computeUncoveredSegments(b.y, b.y + b.height, obRightCovering, minSize * 0.5)
363
+ for (const seg of obRightUncovered) {
364
+ pushIfFree(obRightX, seg.center, z)
365
+ }
366
+ }
367
+
368
+ // Top edge of blocker (y = b.y - δ)
369
+ const obTopY = b.y - δ
370
+ if (obTopY > bounds.y + EPS && obTopY < bounds.y + bounds.height - EPS) {
371
+ const obTopCovering = blockers
372
+ .filter(bl => bl !== b && bl.y <= obTopY && bl.y + bl.height >= obTopY)
373
+ .map(bl => ({ start: Math.max(b.x, bl.x), end: Math.min(b.x + b.width, bl.x + bl.width) }))
374
+ const obTopUncovered = computeUncoveredSegments(b.x, b.x + b.width, obTopCovering, minSize * 0.5)
375
+ for (const seg of obTopUncovered) {
376
+ pushIfFree(seg.center, obTopY, z)
377
+ }
378
+ }
379
+
380
+ // Bottom edge of blocker (y = b.y + b.height + δ)
381
+ const obBottomY = b.y + b.height + δ
382
+ if (obBottomY > bounds.y + EPS && obBottomY < bounds.y + bounds.height - EPS) {
383
+ const obBottomCovering = blockers
384
+ .filter(bl => bl !== b && bl.y <= obBottomY && bl.y + bl.height >= obBottomY)
385
+ .map(bl => ({ start: Math.max(b.x, bl.x), end: Math.min(b.x + b.width, bl.x + bl.width) }))
386
+ const obBottomUncovered = computeUncoveredSegments(b.x, b.x + b.width, obBottomCovering, minSize * 0.5)
387
+ for (const seg of obBottomUncovered) {
388
+ pushIfFree(seg.center, obBottomY, z)
389
+ }
390
+ }
391
+ }
392
+ }
393
+
394
+ // Strong multi-layer preference then distance.
395
+ out.sort((a, b) => (b.zSpanLen! - a.zSpanLen!) || (b.distance - a.distance))
396
+ return out
397
+ }