@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.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/bun-formatcheck.yml +26 -0
- package/.github/workflows/bun-pver-release.yml +71 -0
- package/.github/workflows/bun-test.yml +31 -0
- package/.github/workflows/bun-typecheck.yml +26 -0
- package/CLAUDE.md +23 -0
- package/README.md +5 -0
- package/biome.json +93 -0
- package/bun.lock +29 -0
- package/bunfig.toml +5 -0
- package/components/SolverDebugger3d.tsx +833 -0
- package/cosmos.config.json +6 -0
- package/cosmos.decorator.tsx +21 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +921 -0
- package/experiments/rect-fill-2d.tsx +983 -0
- package/experiments/rect3d_visualizer.html +640 -0
- package/global.d.ts +4 -0
- package/index.html +12 -0
- package/lib/index.ts +1 -0
- package/lib/solvers/RectDiffSolver.ts +158 -0
- package/lib/solvers/rectdiff/candidates.ts +397 -0
- package/lib/solvers/rectdiff/engine.ts +355 -0
- package/lib/solvers/rectdiff/geometry.ts +284 -0
- package/lib/solvers/rectdiff/layers.ts +48 -0
- package/lib/solvers/rectdiff/rectsToMeshNodes.ts +22 -0
- package/lib/solvers/rectdiff/types.ts +63 -0
- package/lib/types/capacity-mesh-types.ts +33 -0
- package/lib/types/srj-types.ts +37 -0
- package/package.json +33 -0
- package/pages/example01.page.tsx +11 -0
- package/test-assets/example01.json +933 -0
- package/tests/__snapshots__/svg.snap.svg +3 -0
- package/tests/examples/__snapshots__/example01.snap.svg +121 -0
- package/tests/examples/example01.test.tsx +65 -0
- package/tests/fixtures/preload.ts +1 -0
- package/tests/incremental-solver.test.ts +100 -0
- package/tests/rect-diff-solver.test.ts +154 -0
- package/tests/svg.test.ts +12 -0
- 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
|
+
}
|