Skip to content

Commit c08ade1

Browse files
authored
feat: Add board outline support (#20)
* feat: Remove GapFillSubSolver from RectDiffSolver Disables and removes the GapFillSubSolver from RectDiffSolver. The GAP_FILL phase is now skipped, directly transitioning to DONE. This change involves: - Removing GapFillSubSolver import. - Removing activeSubSolver property and its initialization. - Modifying _step() to skip the GAP_FILL phase. - Removing GapFillSubSolver usage from computeProgress() and visualize(). * feat: Implement board outline support with inverse obstacles - Introduced to to track areas outside the board outline. - Implemented and geometry helpers to calculate void areas. - Updated initialization to add these void areas as obstacles on all layers, preventing expansion outside the board. - Enhanced visualization to render void areas as dark, semi-transparent overlays. - Refactored geometry helpers into separate files for better organization. * fix(viz): Hide outer void padding in visualization and update snapshots * fix(engine): Exclude void rects from final output mesh The void rectangles (generated from board outline) act as internal obstacles during solving but should not be part of the final obstacle output. * refactor: Apply code review feedback (variable naming, EPS import) Applies feedback from the code review, focusing on variable transparency and proper constant importing. - Renamed short/non-descriptive variables (e.g., 'ob', 'r', 'zs') to more transparent names ('obstacle', 'rect', 'zLayers') in 'engine.ts' and 'RectDiffSolver.ts'. - Replaced local 'EPS' definition in 'computeInverseRects.ts' with an import from 'geometry.ts' for consistency.
1 parent e82cff7 commit c08ade1

File tree

7 files changed

+280
-74
lines changed

7 files changed

+280
-74
lines changed

lib/solvers/RectDiffSolver.ts

Lines changed: 48 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -13,36 +13,29 @@ import {
1313
computeProgress,
1414
} from "./rectdiff/engine"
1515
import { rectsToMeshNodes } from "./rectdiff/rectsToMeshNodes"
16+
import { overlaps } from "./rectdiff/geometry"
1617
import type { GapFillOptions } from "./rectdiff/gapfill/types"
1718
import {
1819
findUncoveredPoints,
1920
calculateCoverage,
2021
} from "./rectdiff/gapfill/engine"
21-
import { GapFillSubSolver } from "./rectdiff/subsolvers/GapFillSubSolver"
2222

2323
/**
2424
* A streaming, one-step-per-iteration solver for capacity mesh generation.
2525
*/
2626
export class RectDiffSolver extends BaseSolver {
2727
private srj: SimpleRouteJson
2828
private gridOptions: Partial<GridFill3DOptions>
29-
private gapFillOptions: Partial<GapFillOptions>
3029
private state!: RectDiffState
3130
private _meshNodes: CapacityMeshNode[] = []
3231

33-
/** Active subsolver for GAP_FILL phases. */
34-
declare activeSubSolver: GapFillSubSolver | null
35-
3632
constructor(opts: {
3733
simpleRouteJson: SimpleRouteJson
3834
gridOptions?: Partial<GridFill3DOptions>
39-
gapFillOptions?: Partial<GapFillOptions>
4035
}) {
4136
super()
4237
this.srj = opts.simpleRouteJson
4338
this.gridOptions = opts.gridOptions ?? {}
44-
this.gapFillOptions = opts.gapFillOptions ?? {}
45-
this.activeSubSolver = null
4639
}
4740

4841
override _setup() {
@@ -60,44 +53,7 @@ export class RectDiffSolver extends BaseSolver {
6053
} else if (this.state.phase === "EXPANSION") {
6154
stepExpansion(this.state)
6255
} else if (this.state.phase === "GAP_FILL") {
63-
// Initialize gap fill subsolver if needed
64-
if (
65-
!this.activeSubSolver ||
66-
!(this.activeSubSolver instanceof GapFillSubSolver)
67-
) {
68-
const minTrace = this.srj.minTraceWidth || 0.15
69-
const minGapSize = Math.max(0.01, minTrace / 10)
70-
const boundsSize = Math.min(
71-
this.state.bounds.width,
72-
this.state.bounds.height,
73-
)
74-
this.activeSubSolver = new GapFillSubSolver({
75-
placed: this.state.placed,
76-
options: {
77-
minWidth: minGapSize,
78-
minHeight: minGapSize,
79-
scanResolution: Math.max(0.05, boundsSize / 100),
80-
...this.gapFillOptions,
81-
},
82-
layerCtx: {
83-
bounds: this.state.bounds,
84-
layerCount: this.state.layerCount,
85-
obstaclesByLayer: this.state.obstaclesByLayer,
86-
placedByLayer: this.state.placedByLayer,
87-
},
88-
})
89-
}
90-
91-
this.activeSubSolver.step()
92-
93-
if (this.activeSubSolver.solved) {
94-
// Transfer results back to main state
95-
const output = this.activeSubSolver.getOutput()
96-
this.state.placed = output.placed
97-
this.state.placedByLayer = output.placedByLayer
98-
this.activeSubSolver = null
99-
this.state.phase = "DONE"
100-
}
56+
this.state.phase = "DONE"
10157
} else if (this.state.phase === "DONE") {
10258
// Finalize once
10359
if (!this.solved) {
@@ -112,24 +68,14 @@ export class RectDiffSolver extends BaseSolver {
11268
this.stats.phase = this.state.phase
11369
this.stats.gridIndex = this.state.gridIndex
11470
this.stats.placed = this.state.placed.length
115-
if (this.activeSubSolver instanceof GapFillSubSolver) {
116-
const output = this.activeSubSolver.getOutput()
117-
this.stats.gapsFilled = output.filledCount
118-
}
11971
}
12072

12173
/** Compute solver progress (0 to 1). */
12274
computeProgress(): number {
12375
if (this.solved || this.state.phase === "DONE") {
12476
return 1
12577
}
126-
if (
127-
this.state.phase === "GAP_FILL" &&
128-
this.activeSubSolver instanceof GapFillSubSolver
129-
) {
130-
return 0.85 + 0.1 * this.activeSubSolver.computeProgress()
131-
}
132-
return computeProgress(this.state) * 0.85
78+
return computeProgress(this.state)
13379
}
13480

13581
override getOutput(): { meshNodes: CapacityMeshNode[] } {
@@ -183,11 +129,6 @@ export class RectDiffSolver extends BaseSolver {
183129

184130
/** Streaming visualization: board + obstacles + current placements. */
185131
override visualize(): GraphicsObject {
186-
// If a subsolver is active, delegate to its visualization
187-
if (this.activeSubSolver) {
188-
return this.activeSubSolver.visualize()
189-
}
190-
191132
const rects: NonNullable<GraphicsObject["rects"]> = []
192133
const points: NonNullable<GraphicsObject["points"]> = []
193134
const lines: NonNullable<GraphicsObject["lines"]> = [] // Initialize lines array
@@ -223,12 +164,12 @@ export class RectDiffSolver extends BaseSolver {
223164
}
224165

225166
// obstacles (rect & oval as bounding boxes)
226-
for (const ob of this.srj.obstacles ?? []) {
227-
if (ob.type === "rect" || ob.type === "oval") {
167+
for (const obstacle of this.srj.obstacles ?? []) {
168+
if (obstacle.type === "rect" || obstacle.type === "oval") {
228169
rects.push({
229-
center: { x: ob.center.x, y: ob.center.y },
230-
width: ob.width,
231-
height: ob.height,
170+
center: { x: obstacle.center.x, y: obstacle.center.y },
171+
width: obstacle.width,
172+
height: obstacle.height,
232173
fill: "#fee2e2",
233174
stroke: "#ef4444",
234175
layer: "obstacle",
@@ -237,6 +178,46 @@ export class RectDiffSolver extends BaseSolver {
237178
}
238179
}
239180

181+
// board void rects
182+
if (this.state?.boardVoidRects) {
183+
// If outline exists, compute its bbox to hide outer padding voids
184+
let outlineBBox: {
185+
x: number
186+
y: number
187+
width: number
188+
height: number
189+
} | null = null
190+
191+
if (this.srj.outline && this.srj.outline.length > 0) {
192+
const xs = this.srj.outline.map((p) => p.x)
193+
const ys = this.srj.outline.map((p) => p.y)
194+
const minX = Math.min(...xs)
195+
const minY = Math.min(...ys)
196+
outlineBBox = {
197+
x: minX,
198+
y: minY,
199+
width: Math.max(...xs) - minX,
200+
height: Math.max(...ys) - minY,
201+
}
202+
}
203+
204+
for (const r of this.state.boardVoidRects) {
205+
// If we have an outline, only show voids that overlap its bbox (hides outer padding)
206+
if (outlineBBox && !overlaps(r, outlineBBox)) {
207+
continue
208+
}
209+
210+
rects.push({
211+
center: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
212+
width: r.width,
213+
height: r.height,
214+
fill: "rgba(0, 0, 0, 0.5)",
215+
stroke: "none",
216+
label: "void",
217+
})
218+
}
219+
}
220+
240221
// candidate positions (where expansion started from)
241222
if (this.state?.candidates?.length) {
242223
for (const cand of this.state.candidates) {

lib/solvers/rectdiff/engine.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
overlaps,
2121
subtractRect2D,
2222
} from "./geometry"
23+
import { computeInverseRects } from "./geometry/computeInverseRects"
2324
import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
2425

2526
/**
@@ -44,11 +45,24 @@ export function initState(
4445
{ length: layerCount },
4546
() => [],
4647
)
47-
for (const ob of srj.obstacles ?? []) {
48-
const r = obstacleToXYRect(ob)
49-
if (!r) continue
50-
const zs = obstacleZs(ob, zIndexByName)
51-
const invalidZs = zs.filter((z) => z < 0 || z >= layerCount)
48+
49+
// Compute void rects from outline if present
50+
let boardVoidRects: XYRect[] = []
51+
if (srj.outline && srj.outline.length > 2) {
52+
boardVoidRects = computeInverseRects(bounds, srj.outline)
53+
// Add void rects as obstacles to ALL layers
54+
for (const voidR of boardVoidRects) {
55+
for (let z = 0; z < layerCount; z++) {
56+
obstaclesByLayer[z]!.push(voidR)
57+
}
58+
}
59+
}
60+
61+
for (const obstacle of srj.obstacles ?? []) {
62+
const rect = obstacleToXYRect(obstacle)
63+
if (!rect) continue
64+
const zLayers = obstacleZs(obstacle, zIndexByName)
65+
const invalidZs = zLayers.filter((z) => z < 0 || z >= layerCount)
5266
if (invalidZs.length) {
5367
throw new Error(
5468
`RectDiffSolver: obstacle uses z-layer indices ${invalidZs.join(
@@ -57,8 +71,9 @@ export function initState(
5771
)
5872
}
5973
// Persist normalized zLayers back onto the shared SRJ so downstream solvers see them.
60-
if ((!ob.zLayers || ob.zLayers.length === 0) && zs.length) ob.zLayers = zs
61-
for (const z of zs) obstaclesByLayer[z]!.push(r)
74+
if ((!obstacle.zLayers || obstacle.zLayers.length === 0) && zLayers.length)
75+
obstacle.zLayers = zLayers
76+
for (const z of zLayers) obstaclesByLayer[z]!.push(rect)
6277
}
6378

6479
const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
@@ -97,6 +112,7 @@ export function initState(
97112
bounds,
98113
options,
99114
obstaclesByLayer,
115+
boardVoidRects,
100116
phase: "GRID",
101117
gridIndex: 0,
102118
candidates: [],
@@ -426,7 +442,10 @@ export function finalizeRects(state: RectDiffState): Rect3d[] {
426442
})
427443

428444
// Append obstacle nodes to the output
445+
const voidSet = new Set(state.boardVoidRects || [])
429446
for (const [rect, layerIndices] of layersByObstacleRect.entries()) {
447+
if (voidSet.has(rect)) continue // Skip void rects
448+
430449
out.push({
431450
minX: rect.x,
432451
minY: rect.y,
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { XYRect } from "../types"
2+
import { isPointInPolygon } from "./isPointInPolygon"
3+
import { EPS } from "../geometry" // Import EPS from common geometry file
4+
5+
/**
6+
* Decompose the empty space inside 'bounds' but outside 'polygon' into rectangles.
7+
* This uses a coordinate grid approach, ideal for rectilinear polygons.
8+
*/
9+
export function computeInverseRects(
10+
bounds: XYRect,
11+
polygon: Array<{ x: number; y: number }>,
12+
): XYRect[] {
13+
if (!polygon || polygon.length < 3) return []
14+
15+
// 1. Collect unique sorted X and Y coordinates
16+
const xs = new Set<number>([bounds.x, bounds.x + bounds.width])
17+
const ys = new Set<number>([bounds.y, bounds.y + bounds.height])
18+
for (const p of polygon) {
19+
xs.add(p.x)
20+
ys.add(p.y)
21+
}
22+
const xSorted = Array.from(xs).sort((a, b) => a - b)
23+
const ySorted = Array.from(ys).sort((a, b) => a - b)
24+
25+
// 2. Generate grid cells and classify them
26+
const rawRects: XYRect[] = []
27+
for (let i = 0; i < xSorted.length - 1; i++) {
28+
for (let j = 0; j < ySorted.length - 1; j++) {
29+
const x0 = xSorted[i]!
30+
const x1 = xSorted[i + 1]!
31+
const y0 = ySorted[j]!
32+
const y1 = ySorted[j + 1]!
33+
34+
// Check center point
35+
const cx = (x0 + x1) / 2
36+
const cy = (y0 + y1) / 2
37+
38+
// If NOT in polygon, it's a void rect
39+
if (
40+
cx >= bounds.x &&
41+
cx <= bounds.x + bounds.width &&
42+
cy >= bounds.y &&
43+
cy <= bounds.y + bounds.height
44+
) {
45+
if (!isPointInPolygon({ x: cx, y: cy }, polygon)) {
46+
rawRects.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
47+
}
48+
}
49+
}
50+
}
51+
52+
// 3. Simple merge pass (horizontal)
53+
const finalRects: XYRect[] = []
54+
55+
// Sort by y then x
56+
rawRects.sort((a, b) => {
57+
if (Math.abs(a.y - b.y) > EPS) return a.y - b.y
58+
return a.x - b.x
59+
})
60+
61+
let current: XYRect | null = null
62+
for (const r of rawRects) {
63+
if (!current) {
64+
current = r
65+
continue
66+
}
67+
68+
const sameY = Math.abs(current.y - r.y) < EPS
69+
const sameHeight = Math.abs(current.height - r.height) < EPS
70+
const touchesX = Math.abs(current.x + current.width - r.x) < EPS
71+
72+
if (sameY && sameHeight && touchesX) {
73+
current.width += r.width
74+
} else {
75+
finalRects.push(current)
76+
current = r
77+
}
78+
}
79+
if (current) finalRects.push(current)
80+
81+
// 4. Vertical merge pass
82+
finalRects.sort((a, b) => {
83+
if (Math.abs(a.x - b.x) > EPS) return a.x - b.x
84+
return a.y - b.y
85+
})
86+
87+
const mergedVertical: XYRect[] = []
88+
current = null
89+
for (const r of finalRects) {
90+
if (!current) {
91+
current = r
92+
continue
93+
}
94+
const sameX = Math.abs(current.x - r.x) < EPS
95+
const sameWidth = Math.abs(current.width - r.width) < EPS
96+
const touchesY = Math.abs(current.y + current.height - r.y) < EPS
97+
98+
if (sameX && sameWidth && touchesY) {
99+
current.height += r.height
100+
} else {
101+
mergedVertical.push(current)
102+
current = r
103+
}
104+
}
105+
if (current) mergedVertical.push(current)
106+
107+
return mergedVertical
108+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Check if a point is inside a polygon using ray casting.
3+
*/
4+
export function isPointInPolygon(
5+
p: { x: number; y: number },
6+
polygon: Array<{ x: number; y: number }>,
7+
): boolean {
8+
let inside = false
9+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
10+
const xi = polygon[i]!.x,
11+
yi = polygon[i]!.y
12+
const xj = polygon[j]!.x,
13+
yj = polygon[j]!.y
14+
15+
const intersect =
16+
yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi
17+
if (intersect) inside = !inside
18+
}
19+
return inside
20+
}

lib/solvers/rectdiff/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export type RectDiffState = {
4949
maxMultiLayerSpan: number | undefined
5050
}
5151
obstaclesByLayer: XYRect[][]
52+
boardVoidRects: XYRect[] // newly added for viz
5253

5354
// evolving
5455
phase: Phase

0 commit comments

Comments
 (0)