Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions lib/solvers/RectDiffSolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
findUncoveredPoints,
calculateCoverage,
} from "./rectdiff/gapfill/engine"
import { EdgeExpansionGapFillSubSolver } from "./rectdiff/subsolvers/EdgeExpansionGapFillSubSolver"

/**
* A streaming, one-step-per-iteration solver for capacity mesh generation.
Expand All @@ -28,6 +29,7 @@ export class RectDiffSolver extends BaseSolver {
private gridOptions: Partial<GridFill3DOptions>
private state!: RectDiffState
private _meshNodes: CapacityMeshNode[] = []
private gapFillSubSolver?: EdgeExpansionGapFillSubSolver

constructor(opts: {
simpleRouteJson: SimpleRouteJson
Expand All @@ -53,7 +55,43 @@ export class RectDiffSolver extends BaseSolver {
} else if (this.state.phase === "EXPANSION") {
stepExpansion(this.state)
} else if (this.state.phase === "GAP_FILL") {
this.state.phase = "DONE"
// Initialize gap fill subsolver on first entry
if (!this.gapFillSubSolver) {
this.gapFillSubSolver = new EdgeExpansionGapFillSubSolver({
bounds: this.state.bounds,
layerCount: this.state.layerCount,
obstacles: this.state.obstaclesByLayer,
existingPlaced: this.state.placed,
existingPlacedByLayer: this.state.placedByLayer,
options: {
minSingle: this.state.options.minSingle,
minMulti: this.state.options.minMulti,
maxAspectRatio: this.state.options.maxAspectRatio,
maxMultiLayerSpan: this.state.options.maxMultiLayerSpan,
},
})
}

// Step the subsolver
if (!this.gapFillSubSolver.solved) {
this.gapFillSubSolver.step()
} else {
// Merge gap-fill results into main state
const gapFillOutput = this.gapFillSubSolver.getOutput()
this.state.placed.push(...gapFillOutput.newPlaced)

// Update placedByLayer
for (const placed of gapFillOutput.newPlaced) {
for (const z of placed.zLayers) {
if (!this.state.placedByLayer[z]) {
this.state.placedByLayer[z] = []
}
this.state.placedByLayer[z]!.push(placed.rect)
}
}

this.state.phase = "DONE"
}
} else if (this.state.phase === "DONE") {
// Finalize once
if (!this.solved) {
Expand All @@ -75,7 +113,18 @@ export class RectDiffSolver extends BaseSolver {
if (this.solved || this.state.phase === "DONE") {
return 1
}
return computeProgress(this.state)

const baseProgress = computeProgress(this.state)

// If in GAP_FILL phase, factor in subsolver progress
if (this.state.phase === "GAP_FILL" && this.gapFillSubSolver) {
const gapFillProgress = this.gapFillSubSolver.computeProgress()
// GAP_FILL is the last phase before DONE, so weight it appropriately
// Assume GRID+EXPANSION is 90%, GAP_FILL is remaining 10%
return 0.9 + gapFillProgress * 0.1
}

return baseProgress * 0.9 // Scale down to leave room for GAP_FILL
}

override getOutput(): { meshNodes: CapacityMeshNode[] } {
Expand Down Expand Up @@ -129,6 +178,11 @@ export class RectDiffSolver extends BaseSolver {

/** Streaming visualization: board + obstacles + current placements. */
override visualize(): GraphicsObject {
// If in GAP_FILL phase, delegate to subsolver visualization
if (this.state?.phase === "GAP_FILL" && this.gapFillSubSolver) {
return this.gapFillSubSolver.visualize()
}

const rects: NonNullable<GraphicsObject["rects"]> = []
const points: NonNullable<GraphicsObject["points"]> = []
const lines: NonNullable<GraphicsObject["lines"]> = [] // Initialize lines array
Expand Down Expand Up @@ -163,17 +217,23 @@ export class RectDiffSolver extends BaseSolver {
})
}

// obstacles (rect & oval as bounding boxes)
// obstacles (rect & oval as bounding boxes) with layer information
for (const obstacle of this.srj.obstacles ?? []) {
if (obstacle.type === "rect" || obstacle.type === "oval") {
// Get layer information if available
const layerInfo =
obstacle.layers && obstacle.layers.length > 0
? `\nz:${obstacle.layers.join(",")}`
: ""

rects.push({
center: { x: obstacle.center.x, y: obstacle.center.y },
width: obstacle.width,
height: obstacle.height,
fill: "#fee2e2",
stroke: "#ef4444",
layer: "obstacle",
label: "obstacle",
label: `obstacle ${layerInfo}`,
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/solvers/rectdiff/candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function longestFreeSpanAroundZ(params: {
*/
export function computeDefaultGridSizes(bounds: XYRect): number[] {
const ref = Math.max(bounds.width, bounds.height)
return [ref / 8, ref / 16, ref / 32]
return [ref / 32]
}

/**
Expand Down
180 changes: 180 additions & 0 deletions lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// lib/solvers/rectdiff/edge-expansion-gapfill/calculateAvailableSpace.ts
import type { GapFillNode, Direction, EdgeExpansionGapFillState } from "./types"
import type { XYRect } from "../types"

const EPS = 1e-9

/**
* Calculate how far a node can expand in a given direction before hitting blockers.
*
* **What it does:**
* Determines the maximum distance a gap-fill node can expand in a specific direction
* (up, down, left, or right) before it would collide with obstacles, existing capacity
* nodes, or other gap-fill nodes. Returns the available expansion distance in millimeters.
*
* **How it works:**
* 1. **Collects blockers** on the same z-layers as the node:
* - Existing capacity nodes from the main RectDiff solver
* - Board obstacles/components
* - Other gap-fill nodes currently being expanded
* - Previously placed gap-fill nodes
*
* 2. **Calculates initial distance** to the board boundary in the expansion direction
*
* 3. **Checks each blocker** to see if it's in the expansion path:
* - For vertical expansion (up/down): checks if blocker overlaps in x-axis
* - For horizontal expansion (left/right): checks if blocker overlaps in y-axis
* - If blocking, calculates distance to the blocker and takes the minimum
*
* 4. **Returns** the minimum distance (clamped to non-negative)
*
* **Usage:**
* Called during expansion planning to evaluate potential expansion directions.
* The result is then clamped by `calculateMaxExpansion()` to respect aspect ratio
* constraints before actually expanding the node.
*
* **Example:**
* ```
* Node at (10, 10) with size 5x2mm expanding "right"
* Board width: 100mm
* Blocker at (20, 10) with size 3x2mm
*
* Initial distance: 100 - (10 + 5) = 85mm
* Blocker distance: 20 - (10 + 5) = 5mm
* Returns: 5mm (limited by blocker, not board boundary)
* ```
*
* @param params.node - The gap-fill node to check expansion for
* @param params.direction - Direction to expand (up/down/left/right)
* @param ctx - State containing bounds, obstacles, and existing placements
* @returns Available expansion distance in millimeters (0 if blocked immediately)
*
* @internal This is an internal helper function for the edge expansion gap-fill algorithm
*/
export function calculateAvailableSpace(
params: { node: GapFillNode; direction: Direction },
ctx: EdgeExpansionGapFillState,
): number {
const { node, direction } = params
const { bounds, existingPlacedByLayer, obstacles, nodes, newPlaced } = ctx

// Get all potential blockers on the same layers
const blockers: XYRect[] = []

// Add existing placed rects
for (const layer of node.zLayers) {
if (existingPlacedByLayer[layer]) {
blockers.push(...existingPlacedByLayer[layer]!)
}
}

// Add obstacles
for (const layer of node.zLayers) {
if (obstacles[layer]) {
blockers.push(...obstacles[layer]!)
}
}

// Add other gap-fill nodes (already expanded)
for (const otherNode of nodes) {
if (otherNode.id !== node.id) {
blockers.push(otherNode.rect)
}
}

// Add newly placed rects
for (const placed of newPlaced) {
// Check if layers overlap
const hasCommonLayer = node.zLayers.some((z) => placed.zLayers.includes(z))
if (hasCommonLayer) {
blockers.push(placed.rect)
}
}

const rect = node.rect
let maxDistance = Infinity

switch (direction) {
case "up": {
// Expanding upward (increasing y)
maxDistance = bounds.y + bounds.height - (rect.y + rect.height)

for (const obstacle of blockers) {
// Check if obstacle is above and overlaps in x
if (
obstacle.y >= rect.y + rect.height - EPS &&
!(
obstacle.x >= rect.x + rect.width - EPS ||
rect.x >= obstacle.x + obstacle.width - EPS
)
) {
const dist = obstacle.y - (rect.y + rect.height)
maxDistance = Math.min(maxDistance, dist)
}
}
break
}

case "down": {
// Expanding downward (decreasing y)
maxDistance = rect.y - bounds.y

for (const obstacle of blockers) {
// Check if obstacle is below and overlaps in x
if (
obstacle.y + obstacle.height <= rect.y + EPS &&
!(
obstacle.x >= rect.x + rect.width - EPS ||
rect.x >= obstacle.x + obstacle.width - EPS
)
) {
const dist = rect.y - (obstacle.y + obstacle.height)
maxDistance = Math.min(maxDistance, dist)
}
}
break
}

case "right": {
// Expanding rightward (increasing x)
maxDistance = bounds.x + bounds.width - (rect.x + rect.width)

for (const obstacle of blockers) {
// Check if obstacle is to the right and overlaps in y
if (
obstacle.x >= rect.x + rect.width - EPS &&
!(
obstacle.y >= rect.y + rect.height - EPS ||
rect.y >= obstacle.y + obstacle.height - EPS
)
) {
const dist = obstacle.x - (rect.x + rect.width)
maxDistance = Math.min(maxDistance, dist)
}
}
break
}

case "left": {
// Expanding leftward (decreasing x)
maxDistance = rect.x - bounds.x

for (const obstacle of blockers) {
// Check if obstacle is to the left and overlaps in y
if (
obstacle.x + obstacle.width <= rect.x + EPS &&
!(
obstacle.y >= rect.y + rect.height - EPS ||
rect.y >= obstacle.y + obstacle.height - EPS
)
) {
const dist = rect.x - (obstacle.x + obstacle.width)
maxDistance = Math.min(maxDistance, dist)
}
}
break
}
}

return Math.max(0, maxDistance)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// lib/solvers/rectdiff/edge-expansion-gapfill/calculateMaxExpansion.ts
import type { Direction } from "./types"

/**
* Calculate the maximum expansion amount that respects aspect ratio constraint.
*
* When expanding a node, we need to ensure the resulting rectangle doesn't exceed
* the maximum aspect ratio. This function calculates how much we can expand in a
* given direction while staying within the aspect ratio limit.
*
* @param currentWidth - Current width of the node
* @param currentHeight - Current height of the node
* @param direction - Direction of expansion (up/down/left/right)
* @param available - Available space to expand into
* @param maxAspectRatio - Maximum allowed aspect ratio (width/height or height/width), or null for no limit
* @returns Maximum expansion amount that respects aspect ratio, clamped to available space
*/
export function calculateMaxExpansion(params: {
currentWidth: number
currentHeight: number
direction: Direction
available: number
maxAspectRatio: number | null
}): number {
const { currentWidth, currentHeight, direction, available, maxAspectRatio } =
params

// If no aspect ratio constraint, return full available space
if (maxAspectRatio === null) {
return available
}

let maxExpansion = available

if (direction === "left" || direction === "right") {
// Expanding horizontally
// We want: (currentWidth + expansion) / currentHeight <= maxAspectRatio
// So: expansion <= currentHeight * maxAspectRatio - currentWidth
const maxWidth = currentHeight * maxAspectRatio
maxExpansion = Math.min(available, maxWidth - currentWidth)
} else {
// Expanding vertically (up or down)
// We want: (currentHeight + expansion) / currentWidth <= maxAspectRatio
// So: expansion <= currentWidth * maxAspectRatio - currentHeight
const maxHeight = currentWidth * maxAspectRatio
maxExpansion = Math.min(available, maxHeight - currentHeight)
}

// Ensure we don't return negative values
return Math.max(0, maxExpansion)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// lib/solvers/rectdiff/edge-expansion-gapfill/calculatePotentialArea.ts
import type { GapFillNode, Direction, EdgeExpansionGapFillState } from "./types"
import { calculateAvailableSpace } from "./calculateAvailableSpace"

export function calculatePotentialArea(
params: { node: GapFillNode; direction: Direction },
ctx: EdgeExpansionGapFillState,
): number {
const { node, direction } = params
const available = calculateAvailableSpace({ node, direction }, ctx)

if (direction === "up" || direction === "down") {
return available * node.rect.width
} else {
return available * node.rect.height
}
}
15 changes: 15 additions & 0 deletions lib/solvers/rectdiff/edge-expansion-gapfill/computeProgress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// lib/solvers/rectdiff/edge-expansion-gapfill/computeProgress.ts
import type { EdgeExpansionGapFillState } from "./types"

export function computeProgress(state: EdgeExpansionGapFillState): number {
if (state.phase === "DONE") {
return 1
}

const totalObstacles = state.edgeExpansionObstacles.length
if (totalObstacles === 0) {
return 1
}

return state.currentObstacleIndex / totalObstacles
}
Loading