diff --git a/.github/assets/screenshot.png b/.github/assets/screenshot.png index 626a4b7..731e029 100644 Binary files a/.github/assets/screenshot.png and b/.github/assets/screenshot.png differ diff --git a/bun.lockb b/bun.lockb index 81d0e42..d7a3099 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/npm/package.json b/npm/package.json index 16714a1..e9491cc 100644 --- a/npm/package.json +++ b/npm/package.json @@ -37,6 +37,8 @@ "types": "./dist/index.d.ts", "scripts": { "check": "check -b ../node_modules/.bin", + "test": "bun test", + "test:watch": "bun test --watch", "dev": "bun test --watch", "build": "bun build.ts", "prepare": "bun run build" diff --git a/npm/src/__tests__/geometry.test.ts b/npm/src/__tests__/geometry.test.ts deleted file mode 100644 index eced8f4..0000000 --- a/npm/src/__tests__/geometry.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect } from 'bun:test'; -import { Rectangle } from '../geometry'; - -describe('Geometry Utils', () => { - describe('Rectangle', () => { - describe('isInside', () => { - it('should return true when fully inside', () => { - const rect1 = new Rectangle(null, 1, 1, 2, 2); - const rect2 = new Rectangle(null, 0, 0, 5, 5); - expect(rect1.isInside(rect2)).toBe(true); - }); - - it('should return true when sides are withing a threshold', () => { - const rect1 = new Rectangle(null, 0, 0, 5, 5); - const rect2 = new Rectangle(null, -0.1, -0.1, 5.2, 5.2); - expect(rect1.isInside(rect2, 0.1)).toBe(true); - }); - - it('should return false when partially outside', () => { - const rect1 = new Rectangle(null, 4, 4, 2, 2); - const rect2 = new Rectangle(null, 0, 0, 5, 5); - expect(rect1.isInside(rect2)).toBe(false); - }); - - it('should return false when completely outside', () => { - const rect1 = new Rectangle(null, 6, 6, 2, 2); - const rect2 = new Rectangle(null, 0, 0, 5, 5); - expect(rect1.isInside(rect2)).toBe(false); - }); - }); - - describe('isIntersecting', () => { - it('should return true when edges touch', () => { - const rect1 = new Rectangle(null, 4, 0, 1, 5); - const rect2 = new Rectangle(null, 0, 0, 5, 5); - expect(rect1.isIntersecting(rect2)).toBe(true); - }); - - it('should return true when partially intersecting', () => { - const rect1 = new Rectangle(null, 3, 3, 3, 3); - const rect2 = new Rectangle(null, 0, 0, 5, 5); - expect(rect1.isIntersecting(rect2)).toBe(true); - }); - - it('should return true when sides are withing a threshold', () => { - const rect1 = new Rectangle(null, 5, 5, 1, 1); - const rect2 = new Rectangle(null, 0, 0, 5, 5); - expect(rect1.isIntersecting(rect2, 0.1)).toBe(true); - }); - - it('should return false when completely separate', () => { - const rect1 = new Rectangle(null, 6, 6, 2, 2); - const rect2 = new Rectangle(null, 0, 0, 5, 5); - expect(rect1.isIntersecting(rect2)).toBe(false); - }); - }); - }); -}); diff --git a/npm/src/geometry.ts b/npm/src/geometry.ts deleted file mode 100644 index bd8bb1f..0000000 --- a/npm/src/geometry.ts +++ /dev/null @@ -1,318 +0,0 @@ -import type { PartToCut, Stock, Config, BoardLayout } from './types'; -import { Distance } from './units'; - -export class Rectangle { - /** - * In meters - */ - x: number; - /** - * In meters - */ - y: number; - /** - * In meters - */ - width: number; - /** - * In meters - */ - height: number; - - constructor( - readonly data: TData = undefined as TData, - x: number = 0, - y: number = 0, - width: number = 0, - height: number = 0, - ) { - // Make sure width and height are positive - this.x = Math.min(x, x + width); - this.y = Math.min(y, y + height); - this.width = Math.abs(width); - this.height = Math.abs(height); - } - - get left(): number { - return this.x; - } - - get right(): number { - return this.x + this.width; - } - - get top(): number { - return this.y + this.height; - } - - get bottom(): number { - return this.y; - } - - get center(): Point { - return { - x: this.x + this.width / 2, - y: this.y + this.height / 2, - }; - } - - get area(): number { - return this.width * this.height; - } - - pad(padding: { - left?: number; - right?: number; - top?: number; - bottom?: number; - }): Rectangle { - // Adjust x, y, width, and height according to padding - const x = this.x - (padding.left ?? 0); - const y = this.y - (padding.bottom ?? 0); - const width = this.width + (padding.left ?? 0) + (padding.right ?? 0); - const height = this.height + (padding.bottom ?? 0) + (padding.top ?? 0); - - return new Rectangle(this.data, x, y, width, height); - } - - isInside(other: Rectangle, threshold = 1e-5): boolean { - return ( - this.left >= other.left - threshold && - this.right <= other.right + threshold && - this.top <= other.top + threshold && - this.bottom >= other.bottom - threshold - ); - } - - isIntersecting(other: Rectangle, threshold = 1e-5): boolean { - return !( - this.right < other.left - threshold || - this.left > other.right + threshold || - this.top < other.bottom - threshold || - this.bottom > other.top + threshold - ); - } - - growTo(other: Rectangle): Rectangle { - const right = Math.max(this.right, other.right); - const top = Math.max(this.top, other.top); - const left = Math.min(this.left, other.left); - const bottom = Math.min(this.bottom, other.bottom); - const width = right - left; - const height = top - bottom; - return new Rectangle(undefined, left, bottom, width, height); - } - - translate(x: number, y: number): Rectangle { - return new Rectangle( - this.data, - this.x + x, - this.y + y, - this.width, - this.height, - ); - } - - flipYAxis(): Rectangle { - return new Rectangle(this.data, this.x, -this.y, this.width, -this.height); - } - - toString() { - return JSON.stringify(this); - } -} - -export interface Point { - /** - * In meters - */ - x: number; - /** - * In meters - */ - y: number; -} - -export class BoardLayouter { - readonly placements: Rectangle[] = []; - private readonly paddedStock: Rectangle; - - constructor( - readonly stock: Rectangle, - readonly config: Config, - ) { - const padding = -new Distance(config.extraSpace).m; - this.paddedStock = stock.pad({ right: padding, top: padding }); - } - - tryAddPart(part: PartToCut): boolean { - if (!isValidStock(this.stock.data, part)) return false; - - switch (this.config.optimize) { - case 'space': - return this.tryAddPartTight(part); - case 'cuts': - return this.tryAddPartVertical(part); - default: - return false; - } - } - - private tryAddPartFn( - part: PartToCut, - getPossiblePositions: () => Point[], - ): boolean { - const possiblePositions: Point[] = - this.placements.length === 0 - ? // Always position bottom left when empty - [{ x: this.paddedStock.x, y: this.paddedStock.y }] - : // Get possible locations from callback - getPossiblePositions(); - - const placement = possiblePositions - .map( - ({ x, y }) => - new Rectangle(part, x, y, part.size.width, part.size.length), - ) - .find( - (placement) => - placement.isInside(this.paddedStock) && - this.placements.every((p) => !placement.isIntersecting(p)), - ); - - if (placement) { - this.placements.push(placement); - return true; - } else { - return false; - } - } - - tryAddPartTight(part: PartToCut): boolean { - return this.tryAddPartFn(part, () => - this.placements - .flatMap((existing) => { - const bladeWidth = new Distance(this.config.bladeWidth).m; - return [ - // Left of stock and top of existing - { x: this.paddedStock.x, y: existing.top + bladeWidth }, - // left of existing, bottom of stock - { x: existing.right + bladeWidth, y: this.paddedStock.y }, - - // Left of existing, top of other existing - ...this.placements.map((existing2) => ({ - x: existing.right + bladeWidth, - y: existing2.top + bladeWidth, - })), - ]; - }) - // Pack tight to the bottom left (prefer bottom over left) - .toSorted((a, b) => { - if (Math.abs(a.y - b.y) > 1e-5) return a.y - b.y; - if (Math.abs(a.x - b.x) > 1e-5) return a.x - b.x; - return 0; - }), - ); - } - - tryAddPartVertical(part: PartToCut): boolean { - return this.tryAddPartFn(part, () => { - // Group items with same X - const columns = this.placements.reduce< - Map[]> - >((acc, placement) => { - const items = acc.get(placement.x) ?? []; - items.push(placement); - acc.set(placement.x, items); - return acc; - }, new Map()); - - // Remove columns with a different widths - const sameWidthColumns = [...columns.entries()].filter( - ([_, items]) => Math.abs(items[0].width - part.size.width) < 1e-5, - ); - - // Add the spot above the last item in each column - const bladeWidth = new Distance(this.config.bladeWidth).m; - const possiblePlacements: Rectangle[] = [ - ...sameWidthColumns.values(), - ].map(([x, items]) => { - const last = items.at(-1); - const y = last == null ? 0 : last.top + bladeWidth; - return new Rectangle(part, x, y, part.size.width, part.size.length); - }); - - // Create a new column for it to be placed in - const lastRight = [...columns.values()].reduce( - (acc, items) => Math.max(acc, items[0].right), - 0, - ); - const newColumnX = lastRight === 0 ? 0 : lastRight + bladeWidth; - possiblePlacements.push( - new Rectangle(part, newColumnX, 0, part.size.width, part.size.length), - ); - - return ( - possiblePlacements - // Pack tight to the bottom left (prefer left over bottom) - .toSorted((a, b) => { - if (Math.abs(a.x - b.x) > 1e-5) return a.x - b.x; - if (Math.abs(a.y - b.y) > 1e-5) return a.y - b.y; - return 0; - }) - ); - }); - } - - reduceStock(allStock: Rectangle[]): BoardLayouter { - const validStock = allStock.filter((stock) => - isValidStock(stock.data, this.paddedStock.data), - ); - const validLayouts = validStock - .map((stock) => { - const layout = new BoardLayouter(stock, this.config); - this.placements.forEach(({ data: part }) => { - layout.tryAddPart(part); - }); - return layout; - }) - .filter((layout) => layout.placements.length === this.placements.length); - validLayouts.push(this); - return validLayouts.toSorted((a, b) => a.stock.area - b.stock.area)[0]; - } - - toBoardLayout(): BoardLayout { - return { - stock: { - material: this.stock.data.material, - widthM: this.stock.data.width, - lengthM: this.stock.data.length, - thicknessM: this.stock.data.thickness, - }, - placements: this.placements.map((item) => ({ - partNumber: item.data.partNumber, - instanceNumber: item.data.instanceNumber, - name: item.data.name, - material: item.data.material, - xM: item.x, - yM: item.y, - widthM: item.data.size.width, - lengthM: item.data.size.length, - thicknessM: item.data.size.thickness, - bottomM: item.bottom, - leftM: item.left, - rightM: item.right, - topM: item.top, - })), - }; - } -} - -export function isValidStock(test: Stock, target: PartToCut | Stock) { - const targetThickness = - 'size' in target ? target.size.thickness : target.thickness; - return ( - Math.abs(targetThickness - test.thickness) < 1e-5 && - test.material === target.material - ); -} diff --git a/npm/src/geometry/Point.ts b/npm/src/geometry/Point.ts new file mode 100644 index 0000000..ba7bf40 --- /dev/null +++ b/npm/src/geometry/Point.ts @@ -0,0 +1,21 @@ +export class Point { + constructor( + readonly x: number, + readonly y: number, + ) {} + + clone(changes?: { x?: number; y?: number }): Point { + return new Point(changes?.x ?? this.x, changes?.y ?? this.y); + } + + add(x: number, y: number): Point { + return this.clone({ + x: this.x + x, + y: this.y + y, + }); + } + + sub(x: number, y: number): Point { + return this.add(-x, -y); + } +} diff --git a/npm/src/geometry/Rectangle.ts b/npm/src/geometry/Rectangle.ts new file mode 100644 index 0000000..b663e10 --- /dev/null +++ b/npm/src/geometry/Rectangle.ts @@ -0,0 +1,163 @@ +import { Point } from './Point'; +import { + isNearlyLessThan, + isNearlyGreaterThan, + isNearlyLessThanOrEqual, + isNearlyGreaterThanOrEqual, +} from '../utils/floating-point-utils'; + +/** + * Rectangle with a coordinate system based in the normal cartesion coordinate + * system, where (0, 0) is in the bottom left. + */ +export class Rectangle { + readonly left: number; + readonly bottom: number; + readonly width: number; + readonly height: number; + + constructor( + readonly data: T, + x: number, + y: number, + width: number, + height: number, + ) { + this.left = Math.min(x + width, x); + this.bottom = Math.min(y + height, y); + this.width = Math.abs(width); + this.height = Math.abs(height); + } + + toString() { + return JSON.stringify(this); + } + + get right(): number { + return this.left + this.width; + } + + get top(): number { + return this.bottom + this.height; + } + + get center(): Point { + return new Point(this.left + this.width / 2, this.bottom + this.height / 2); + } + + get bottomLeft(): Point { + return new Point(this.left, this.bottom); + } + + get topLeft(): Point { + return new Point(this.left, this.top); + } + + get bottomRight(): Point { + return new Point(this.right, this.bottom); + } + + get topRight(): Point { + return new Point(this.right, this.top); + } + + clone(changes?: { + left?: number; + bottom?: number; + width?: number; + height?: number; + }): Rectangle { + return new Rectangle( + this.data, + changes?.left ?? this.left, + changes?.bottom ?? this.bottom, + changes?.width ?? this.width, + changes?.height ?? this.height, + ); + } + + moveTo(point: Point): Rectangle { + return this.clone({ + left: point.x, + bottom: point.y, + }); + } + + /** + * Return an expanded rectangle. Use negative numbers to shrink the rectangle. + */ + pad(p: { + left?: number; + right?: number; + top?: number; + bottom?: number; + }): Rectangle { + return this.clone({ + left: this.left - (p.left ?? 0), + bottom: this.bottom - (p.bottom ?? 0), + width: this.width + (p.left ?? 0) + (p.right ?? 0), + height: this.height + (p.bottom ?? 0) + (p.top ?? 0), + }); + } + + /** + * Move the rectangle over by the desired X/Y amounts. + */ + translate(x: number, y: number): Rectangle { + return this.clone({ + left: this.left + x, + bottom: this.bottom + y, + }); + } + + /** + * Expand this rectangle to contain another. + */ + swallow(other: Rectangle): Rectangle { + const left = Math.min(this.left, other.left); + const bottom = Math.min(this.bottom, other.bottom); + const right = Math.max(this.right, other.right); + const top = Math.max(this.top, other.top); + return this.clone({ + left, + bottom, + width: right - left, + height: top - bottom, + }); + } + + /** + * Flip the width and height, keeping it in the same left/bottom. + */ + flipOrientation(): Rectangle { + return this.clone({ + width: this.height, + height: this.width, + }); + } + + /** + * Returns true if the other rectangle is inside or the edges are coincident. + */ + isInside(other: Rectangle, epsilon: number): boolean { + return ( + isNearlyGreaterThanOrEqual(this.left, other.left, epsilon) && + isNearlyLessThanOrEqual(this.right, other.right, epsilon) && + isNearlyLessThanOrEqual(this.top, other.top, epsilon) && + isNearlyGreaterThanOrEqual(this.bottom, other.bottom, epsilon) + ); + } + + /** + * Returns true when the other rectangle is inside this one. Returns false if + * they're simply touching. + */ + isIntersecting(other: Rectangle, epsilon: number): boolean { + return !( + isNearlyLessThanOrEqual(this.right, other.left, epsilon) || + isNearlyGreaterThanOrEqual(this.left, other.right, epsilon) || + isNearlyLessThanOrEqual(this.top, other.bottom, epsilon) || + isNearlyGreaterThanOrEqual(this.bottom, other.top, epsilon) + ); + } +} diff --git a/npm/src/geometry/__tests__/Rectangle.test.ts b/npm/src/geometry/__tests__/Rectangle.test.ts new file mode 100644 index 0000000..4857632 --- /dev/null +++ b/npm/src/geometry/__tests__/Rectangle.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'bun:test'; +import { Rectangle } from '../Rectangle'; + +const epsilon = 1e-5; + +describe('Rectangle', () => { + describe('constructor', () => { + it('should support positive sizes', () => { + const left = 4; + const bottom = 6; + const width = 2; + const height = 3; + const expected = expect.objectContaining({ + left, + bottom, + right: 6, + top: 9, + width, + height, + }); + + const actual = new Rectangle(null, left, bottom, width, height); + + expect(actual).toEqual(expected); + }); + + it('should support negative sizes', () => { + const left = 4; + const bottom = 6; + const width = -2; + const height = -3; + const expected = expect.objectContaining({ + left: 2, + bottom: 3, + right: 4, + top: 6, + width: 2, + height: 3, + }); + + const actual = new Rectangle(null, left, bottom, width, height); + + expect(actual).toEqual(expected); + }); + }); + + describe('pad', () => { + const rect = new Rectangle(null, 1, 1, 1, 1); + const expected = new Rectangle(null, 0, -2, 4, 8); + const actual = rect.pad({ + left: 1, + right: 2, + bottom: 3, + top: 4, + }); + + expect(actual).toEqual(expected); + }); + + describe('swallow', () => { + it('should produce the same expanded rectangle regardless of the order', () => { + const rect1 = new Rectangle(null, 0, 0, 1, 1); + const rect2 = new Rectangle(null, 4, 4, 1, 1); + const expected = new Rectangle(null, 0, 0, 5, 5); + + expect(rect1.swallow(rect2)).toEqual(expected); + expect(rect2.swallow(rect1)).toEqual(expected); + }); + }); + + describe('flipOrientation', () => { + it('should flip the width and height', () => { + const rect = new Rectangle(null, 1, 1, 1, 2); + const expected = new Rectangle(null, 1, 1, 2, 1); + + const actual = rect.flipOrientation(); + + expect(actual).toEqual(expected); + }); + }); + + describe('isInside', () => { + it('should return true when fully inside', () => { + const rect1 = new Rectangle(null, 1, 1, 2, 2); + const rect2 = new Rectangle(null, 0, 0, 5, 5); + expect(rect1.isInside(rect2, epsilon)).toBe(true); + }); + + it('should return true when sides are within a threshold', () => { + const rect1 = new Rectangle(null, 0, 0, 5, 5); + const rect2 = new Rectangle(null, -0.1, -0.1, 5.2, 5.2); + expect(rect1.isInside(rect2, 0.1)).toBe(true); + }); + + it('should return false when partially outside', () => { + const rect1 = new Rectangle(null, 4, 4, 2, 2); + const rect2 = new Rectangle(null, 0, 0, 5, 5); + expect(rect1.isInside(rect2, epsilon)).toBe(false); + }); + + it('should return false when completely outside', () => { + const rect1 = new Rectangle(null, 6, 6, 2, 2); + const rect2 = new Rectangle(null, 0, 0, 5, 5); + expect(rect1.isInside(rect2, epsilon)).toBe(false); + }); + }); + + describe('isIntersecting', () => { + it('should return false when edges touch', () => { + const rect1 = new Rectangle(null, 4, 0, 1, 5); + const rect2 = new Rectangle(null, 0, 0, 5, 5); + expect(rect1.isIntersecting(rect2, epsilon)).toBe(true); + }); + + it('should return true when partially intersecting', () => { + const rect1 = new Rectangle(null, 3, 3, 3, 3); + const rect2 = new Rectangle(null, 0, 0, 5, 5); + expect(rect1.isIntersecting(rect2, epsilon)).toBe(true); + }); + + it('should return false when sides are within a threshold', () => { + const rect1 = new Rectangle(null, 5, 5, 1, 1); + const rect2 = new Rectangle(null, 0, 0, 5, 5); + expect(rect1.isIntersecting(rect2, 0.1)).toBe(false); + }); + + it('should return false when completely separate', () => { + const rect1 = new Rectangle(null, 6, 6, 2, 2); + const rect2 = new Rectangle(null, 0, 0, 5, 5); + expect(rect1.isIntersecting(rect2, epsilon)).toBe(false); + }); + }); +}); diff --git a/npm/src/geometry/index.ts b/npm/src/geometry/index.ts new file mode 100644 index 0000000..a3677f2 --- /dev/null +++ b/npm/src/geometry/index.ts @@ -0,0 +1,3 @@ +export * from './Point'; +export * from './Rectangle'; +export * from '../utils/floating-point-utils'; diff --git a/npm/src/index.ts b/npm/src/index.ts index 7313342..196b11c 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -5,95 +5,68 @@ import { Config, type BoardLayout, type BoardLayoutLeftover, + type BoardLayoutPlacement, + type PotentialBoardLayout, } from './types'; import consola from 'consola'; -import { BoardLayouter, Rectangle, isValidStock } from './geometry'; -import { Distance } from './units'; +import { Rectangle } from './geometry'; +import { isValidStock } from './utils/stock-utils'; +import { Distance } from './utils/units'; +import { + createCutPacker, + createTightPacker, + type PackOptions, + type Packer, +} from './packers'; +import type { Visualizer } from './visualizers'; export * from './types'; -export * from './units'; +export * from './utils/units'; /** * Given a list of parts, stock, and some configuration, return the board * layouts (where each part goes on stock) and all the leftover parts that * couldn't be placed. + * + * General order of operations: + * 1. Load parts that need to be placed + * 2. Fill stock with parts until no more parts can be placed + * 3. Try and reduce the size of final boards to minimize material usage + * + * The second step, filling the stock, is not simple. There's a few + * implementations: + * - Optimize for space - A simple, greedy algorithm that just packs parts in as + * tight as possible + * - Optimize for cuts - A variant of the [Guillotine cutting algorithm](https://en.wikipedia.org/wiki/Guillotine_cutting) + * that generates part placements that are easy to cut out with a + * table/circular/track saw. */ export function generateBoardLayouts( parts: PartToCut[], stock: StockMatrix[], config: Config, + visualizer?: Visualizer, ): { layouts: BoardLayout[]; leftovers: BoardLayoutLeftover[]; } { config = Config.parse(config); consola.info('Generating board layouts...'); + const packer = PACKERS[config.optimize](visualizer); - // Create geometry for stock and parts - const boards = reduceStockMatrix(stock) - .map((stock) => new Rectangle(stock, 0, 0, stock.width, stock.length)) - .toSorted((a, b) => b.area - a.area); - if (boards.length === 0) { - throw Error('You must include at least 1 stock.'); - } - - // Generate the layouts - const partQueue = [...parts].sort( - // Sort by material, thickness, and area to ensure parts of the same - // material and thickness are placed together, and that larger items are - // placed first. - (a, b) => { - const materialCompare = a.material.localeCompare(b.material); - if (materialCompare != 0) return materialCompare; - - const thicknessCompare = b.size.thickness - a.size.thickness; - if (Math.abs(thicknessCompare) > 1e-5) return thicknessCompare; - - return b.size.width * b.size.length - a.size.width * a.size.length; - }, + const boards = reduceStockMatrix(stock).toSorted( + (a, b) => b.width * b.length - a.width * a.length, ); - const leftovers: PartToCut[] = []; - const layouts: BoardLayouter[] = []; - while (partQueue.length > 0) { - const part = partQueue.shift()!; - const addedToExisting = layouts.find((layout) => layout.tryAddPart(part)); - if (addedToExisting) { - continue; - } + if (boards.length === 0) throw Error('You must include at least 1 stock.'); - const matchingStock = boards.find((stock) => - isValidStock(stock.data, part), - ); - if (matchingStock == null) { - consola.warn( - `Not stock found for ${part.material} @ ${part.size.thickness}`, - ); - leftovers.push(part); - continue; - } - - const newLayout = new BoardLayouter(matchingStock, config); - const addedToNew = newLayout.tryAddPart(part); - if (addedToNew) { - layouts.push(newLayout); - } else { - leftovers.push(part); - } - } + const { layouts, leftovers } = placeAllParts(config, parts, boards, packer); + const minimizedLayouts = layouts.map((layout) => + minimizeLayoutStock(config, layout, boards, packer), + ); return { - layouts: layouts.map((layout) => - layout.reduceStock(boards).toBoardLayout(), - ), - leftovers: leftovers.map((item) => ({ - instanceNumber: item.instanceNumber, - partNumber: item.partNumber, - name: item.name, - material: item.material, - lengthM: item.size.length, - widthM: item.size.width, - thicknessM: item.size.thickness, - })), + layouts: minimizedLayouts.map(serializeBoardLayoutRectangles), + leftovers: leftovers.map(serializePartToCut), }; } @@ -114,3 +87,189 @@ export function reduceStockMatrix(matrix: StockMatrix[]): Stock[] { ), ); } + +export const PACKERS: Record< + Config['optimize'], + (visualizer?: Visualizer) => Packer +> = { + cuts: createCutPacker, + space: createTightPacker, +}; + +function placeAllParts( + config: Config, + parts: PartToCut[], + stock: Stock[], + packer: Packer, +): { layouts: PotentialBoardLayout[]; leftovers: PartToCut[] } { + const extraSpace = new Distance(config.extraSpace).m; + const unplacedParts = new Set( + [...parts].sort( + // Sort by material, thickness, and area to ensure parts of the same + // material and thickness are placed together, and that larger items are + // placed first. + (a, b) => { + const materialCompare = a.material.localeCompare(b.material); + if (materialCompare != 0) return materialCompare; + + const thicknessCompare = b.size.thickness - a.size.thickness; + if (Math.abs(thicknessCompare) > 1e-5) return thicknessCompare; + + return b.size.width * b.size.length - a.size.width * a.size.length; + }, + ), + ); + const leftovers: PartToCut[] = []; + const layouts: PotentialBoardLayout[] = []; + + while (unplacedParts.size > 0) { + // Extract all parts from queue, will add them back if not placed + const unplacedPartsArray = [...unplacedParts]; + const targetPart = unplacedPartsArray[0]; + + // Find board to put part on + // Add a new board if one doesn't match the part + const board = stock.find((board) => + isValidStock(board, targetPart, config.precision), + ); + if (board == null) { + console.warn(`Board not found for part:`, targetPart); + unplacedParts.delete(targetPart); + leftovers.push(targetPart); + continue; + } + + const layout: PotentialBoardLayout = { + placements: [], + stock: board, + }; + const boardRect = new Rectangle( + board, + 0, + 0, + board.width - extraSpace, + board.length - extraSpace, + ); + console.log({ boardRect, board, extraSpace }); + + // Fill the bin + const partsToPlace = unplacedPartsArray + .filter((part) => isValidStock(board, part, config.precision)) + .map( + (part) => new Rectangle(part, 0, 0, part.size.width, part.size.length), + ); + + // Fill the layout + const res = packer.pack(boardRect, partsToPlace, getPackerOptions(config)); + if (res.placements.length > 0) { + layouts.push(layout); + res.placements.forEach((placement) => { + layout.placements.push(placement); + unplacedParts.delete(placement.data); + }); + } else { + res.leftovers.forEach((part) => { + leftovers.push(part); + unplacedParts.delete(part); + }); + } + } + + return { + layouts, + leftovers, + }; +} + +/** + * Given a layout, return a new layout on a smaller peice of stock, if + * possible. If a smaller stock cannot be found, return the same layout. + */ +function minimizeLayoutStock( + config: Config, + originalLayout: PotentialBoardLayout, + stock: Stock[], + packer: Packer, +): PotentialBoardLayout { + const extraSpace = new Distance(config.extraSpace).m; + + // Get alternative stock, smaller areas first. + const altStock = stock + .filter((stock) => + isValidStock(originalLayout.stock, stock, config.precision), + ) + .toSorted((a, b) => a.width * a.length - b.width * b.length); + + for (const smallerStock of altStock) { + const bin = new Rectangle( + smallerStock, + 0, + 0, + smallerStock.width - extraSpace, + smallerStock.length - extraSpace, + ); + const rects = [...originalLayout.placements]; + const res = packer.pack(bin, rects, getPackerOptions(config)); + + // Return the new layout if there are no leftovers + if (res.leftovers.length === 0) + return { + stock: smallerStock, + placements: res.placements, + }; + } + + return originalLayout; +} + +function getPackerOptions(config: Config): PackOptions { + return { + allowRotations: true, + gap: new Distance(config.bladeWidth).m, + precision: config.precision, + }; +} + +function serializeBoardLayoutRectangles( + layout: PotentialBoardLayout, +): BoardLayout { + return { + placements: layout.placements.map(serializePartToCutPlacement), + stock: { + material: layout.stock.material, + thicknessM: layout.stock.thickness, + widthM: layout.stock.width, + lengthM: layout.stock.length, + }, + }; +} + +function serializePartToCutPlacement( + placement: Rectangle, +): BoardLayoutPlacement { + return { + instanceNumber: placement.data.instanceNumber, + partNumber: placement.data.partNumber, + name: placement.data.name, + material: placement.data.material, + leftM: placement.left, + rightM: placement.right, + topM: placement.top, + bottomM: placement.bottom, + lengthM: placement.height, + thicknessM: placement.data.size.thickness, + widthM: placement.width, + }; +} + +function serializePartToCut(part: PartToCut): BoardLayoutLeftover { + return { + instanceNumber: part.instanceNumber, + partNumber: part.partNumber, + name: part.name, + material: part.material, + lengthM: part.size.length, + widthM: part.size.width, + thicknessM: part.size.thickness, + }; +} diff --git a/npm/src/packers/CutPacker.ts b/npm/src/packers/CutPacker.ts new file mode 100644 index 0000000..d78478f --- /dev/null +++ b/npm/src/packers/CutPacker.ts @@ -0,0 +1,153 @@ +import { Rectangle, isNearlyEqual } from '../geometry'; +import type { Visualizer } from '../visualizers'; +import type { PackOptions, PackResult, Packer } from './Packer'; +import { createTightPacker } from './TightPacker'; + +/** + * This packer aligns all items by length, and adds each rect in rows until the + * board is filled up. Once full, it tightly packs any parts not placed to fill + * the rest of the bin. + */ +export function createCutPacker(visualizer?: Visualizer): Packer { + const orientateLengthWise = (rect: Rectangle): Rectangle => + rect.width > rect.height ? rect.flipOrientation() : rect; + + const tight = createTightPacker(visualizer); + + const getRows = ( + bin: Rectangle, + placements: Rectangle[], + options: PackOptions, + ) => + placements + .filter((p) => isNearlyEqual(0, p.left, options.precision)) + .map((p) => { + const othersInRow = placements.filter( + (o) => + o !== p && isNearlyEqual(p.bottom, o.bottom, options.precision), + ); + return [p, ...othersInRow]; + }) + .map((rowRects) => { + const rowBin = new Rectangle( + null, + rowRects[0].left, + rowRects[0].bottom, + bin.width, + rowRects[0].height, + ); + return { + rects: rowRects, + bin: rowBin, + }; + }); + + return { + pack(bin, rects, options) { + rects = rects + .map((rect) => orientateLengthWise(rect)) + .toSorted((a, b) => { + if (!isNearlyEqual(a.height, b.height, options.precision)) + return b.height - a.height; + return b.width * b.height - a.width * a.height; + }); + + let lastRowStarter: Rectangle | undefined; + let lastPlacement: Rectangle | undefined; + + const res = rects.reduce<{ + placements: Rectangle[]; + leftovers: Rectangle[]; + }>( + (res, rect) => { + visualizer?.render('start', { res, bin, toPlace: rect }); + const possiblePoints = + !lastPlacement || !lastRowStarter + ? [bin.bottomLeft] + : [ + lastPlacement.bottomRight.add(options.gap, 0), + lastRowStarter.topLeft.add(0, options.gap), + ]; + visualizer?.render('possible-points', { + res, + bin, + toPlace: rect, + possiblePoints, + }); + + const possiblePlacements = possiblePoints.map((point) => + rect.moveTo(point), + ); + const validPlacements = possiblePlacements.filter((p) => + p.isInside(bin, options.precision), + ); + visualizer?.render('placements', { + res, + bin, + toPlace: rect, + validPlacements, + possiblePlacements, + }); + if (validPlacements.length > 0) { + const p = validPlacements[0]; + res.placements.push(p); + lastPlacement = p; + if ( + lastRowStarter == null || + isNearlyEqual(0, p.left, options.precision) + ) { + lastRowStarter = p; + } + } else { + res.leftovers.push(rect); + } + visualizer?.render('placed', { res, bin }); + return res; + }, + { + leftovers: [], + placements: [], + }, + ); + + // Group rectanbles by row + const rows = res.placements + .filter((p) => isNearlyEqual(0, p.left, options.precision)) + .map((p) => { + const othersInRow = res.placements.filter( + (o) => + o !== p && isNearlyEqual(p.bottom, o.bottom, options.precision), + ); + return [p, ...othersInRow]; + }) + .map((rowRects) => { + const rowBin = new Rectangle( + null, + rowRects[0].left, + rowRects[0].bottom, + bin.width, + rowRects[0].height, + ); + return { + rects: rowRects, + bin: rowBin, + }; + }); + + // Tightly pack each row's bin, so the major cuts aren't effected. + const tightRes: PackResult = { + placements: [...res.placements], + leftovers: [], + }; + tight.addToPack(tightRes, bin, res.leftovers, { + ...options, + allowRotations: false, + }); + + return tightRes; + }, + addToPack() { + throw Error('Not supported'); + }, + }; +} diff --git a/npm/src/packers/GenericPacker.ts b/npm/src/packers/GenericPacker.ts new file mode 100644 index 0000000..9014022 --- /dev/null +++ b/npm/src/packers/GenericPacker.ts @@ -0,0 +1,72 @@ +import type { Point, Rectangle } from '../geometry'; +import type { Visualizer } from '../visualizers'; +import type { PackOptions, PackResult, Packer } from './Packer'; +import { isValidPlacement } from './utils'; + +export function createGenericPacker({ + visualizer, + sortPlacements, + getPossiblePlacements, +}: { + visualizer?: Visualizer; + sortPlacements?: (a: Point, b: Point, options: PackOptions) => number; + getPossiblePlacements: ( + bin: Rectangle, + placements: Rectangle[], + gap: number, + ) => Point[]; +}): Packer { + return { + pack(bin, rects, options) { + const res: PackResult = { + leftovers: [], + placements: [], + }; + this.addToPack(res, bin, rects, options); + return res; + }, + addToPack(res, bin, rects, options) { + return rects.reduce>((res, rect) => { + visualizer?.render('start', { res, bin, toPlace: rect }); + const possiblePoints = getPossiblePlacements( + bin, + res.placements, + options.gap, + ); + if (sortPlacements) + possiblePoints.sort((a, b) => sortPlacements(a, b, options)); + visualizer?.render('possible-points', { + res, + bin, + toPlace: rect, + possiblePoints, + }); + const possiblePlacements = possiblePoints.flatMap((point) => { + const moved = rect.moveTo(point); + if (options.allowRotations) { + return [moved, moved.flipOrientation()]; + } + return moved; + }); + + const validPlacements = possiblePlacements.filter((placement) => + isValidPlacement(bin, res.placements, placement, options.precision), + ); + visualizer?.render('placements', { + res, + bin, + toPlace: rect, + validPlacements, + possiblePlacements, + }); + if (validPlacements.length > 0) { + res.placements.push(validPlacements[0]); + } else { + res.leftovers.push(rect.data); + } + visualizer?.render('placed', { res, bin }); + return res; + }, res); + }, + }; +} diff --git a/npm/src/packers/Packer.ts b/npm/src/packers/Packer.ts new file mode 100644 index 0000000..c8504a5 --- /dev/null +++ b/npm/src/packers/Packer.ts @@ -0,0 +1,35 @@ +import type { Rectangle } from '../geometry/Rectangle'; + +/** + * Interface responsible for implementing the bin packing algorithm. + */ +export interface Packer { + pack( + bin: Rectangle, + rects: Rectangle[], + options: PackOptions, + ): PackResult; + addToPack( + res: PackResult, + bin: Rectangle, + rects: Rectangle[], + options: PackOptions, + ): void; +} + +export interface PackOptions { + gap: number; + precision: number; + allowRotations: boolean; +} + +export interface PackResult { + /** + * List of rectangles that fit, translated to their packed location. + */ + placements: Rectangle[]; + /** + * Any rectangles that didn't fit are returned here. Their positions are left untouched. + */ + leftovers: T[]; +} diff --git a/npm/src/packers/TightPacker.ts b/npm/src/packers/TightPacker.ts new file mode 100644 index 0000000..db51786 --- /dev/null +++ b/npm/src/packers/TightPacker.ts @@ -0,0 +1,20 @@ +import { isNearlyEqual } from '../geometry'; +import type { Visualizer } from '../visualizers'; +import { createGenericPacker } from './GenericPacker'; +import type { Packer } from './Packer'; + +export function createTightPacker(visualizer?: Visualizer): Packer { + return createGenericPacker({ + getPossiblePlacements: (bin, placements, gap) => [ + bin.bottomLeft, + ...placements.map((rect) => rect.topLeft.add(0, gap)), + ...placements.map((rect) => rect.bottomRight.add(gap, 0)), + ], + sortPlacements(a, b, options) { + // sort bottom most first, leftmost second + if (!isNearlyEqual(a.y, b.y, options.precision)) return a.y - b.y; + return a.x - b.x; + }, + visualizer, + }); +} diff --git a/npm/src/packers/__tests__/CutPacker.test.ts b/npm/src/packers/__tests__/CutPacker.test.ts new file mode 100644 index 0000000..03c83d6 --- /dev/null +++ b/npm/src/packers/__tests__/CutPacker.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'bun:test'; +import { createCutPacker } from '../CutPacker'; +import type { PackOptions } from '../Packer'; +import { Rectangle } from '../../geometry'; +import { defineSvgVisualizer } from '../../visualizers/SvgVisualizer'; + +describe('Cut Packer', () => { + it('should stack peices lengthwise', () => { + const visualizer = defineSvgVisualizer( + 'src/packers/__tests__/dist/CutPacker/1', + ); + const packer = createCutPacker(visualizer); + const bin = new Rectangle(null, 0, 0, 10, 10); + const rects = [ + new Rectangle('1', 0, 0, 5, 5), + new Rectangle('2', 0, 0, 4, 4), + new Rectangle('3', 0, 0, 3, 3), + new Rectangle('4', 0, 0, 5, 5), + new Rectangle('5', 0, 0, 5, 5), + ]; + const options: PackOptions = { + allowRotations: false, + gap: 0, + precision: 0, + }; + + expect(packer.pack(bin, rects, options)).toEqual({ + placements: [ + expect.objectContaining({ + data: '1', + left: 0, + bottom: 0, + }), + expect.objectContaining({ + data: '4', + left: 5, + bottom: 0, + }), + expect.objectContaining({ + data: '5', + left: 0, + bottom: 5, + }), + expect.objectContaining({ + data: '2', + left: 5, + bottom: 5, + }), + ], + leftovers: ['3'], + }); + }); + + it('should properly sort peices', () => { + const visualizer = defineSvgVisualizer( + 'src/packers/__tests__/dist/CutPacker/2', + ); + const packer = createCutPacker(visualizer); + const bin = new Rectangle(null, 0, 0, 20, 40); + const rects = [ + new Rectangle('1', 0, 0, 5, 5), + new Rectangle('2', 0, 0, 4, 10), + new Rectangle('3', 0, 0, 4, 10), + new Rectangle('4', 0, 0, 3, 3), + new Rectangle('5', 0, 0, 3, 3), + new Rectangle('6', 0, 0, 3, 3), + ]; + const options: PackOptions = { + allowRotations: false, + gap: 0, + precision: 0, + }; + + expect(packer.pack(bin, rects, options)).toEqual({ + placements: [ + expect.objectContaining({ + data: '2', + left: 0, + bottom: 0, + }), + expect.objectContaining({ + data: '3', + left: 4, + bottom: 0, + }), + expect.objectContaining({ + data: '1', + left: 8, + bottom: 0, + }), + expect.objectContaining({ + data: '4', + left: 13, + bottom: 0, + }), + expect.objectContaining({ + data: '5', + left: 16, + bottom: 0, + }), + expect.objectContaining({ + data: '6', + left: 0, + bottom: 10, + }), + ], + leftovers: [], + }); + }); +}); diff --git a/npm/src/packers/__tests__/TightPacker.test.ts b/npm/src/packers/__tests__/TightPacker.test.ts new file mode 100644 index 0000000..196e7af --- /dev/null +++ b/npm/src/packers/__tests__/TightPacker.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'bun:test'; +import { createTightPacker } from '../TightPacker'; +import type { PackOptions } from '../Packer'; +import { Rectangle } from '../../geometry'; +import { defineSvgVisualizer } from '../../visualizers/SvgVisualizer'; + +describe('Tight Bin Packer', () => { + it('should pack rectangles as closely as possible', () => { + const visualizer = defineSvgVisualizer( + 'src/packers/__tests__/dist/TightPacker/1', + ); + const packer = createTightPacker(visualizer); + const bin = new Rectangle(null, 0, 0, 10, 10); + const rects = [ + new Rectangle('1', 0, 0, 5, 5), + new Rectangle('2', 0, 0, 4, 4), + new Rectangle('3', 0, 0, 3, 3), + new Rectangle('4', 0, 0, 5, 5), + new Rectangle('5', 0, 0, 5, 5), + ]; + const options: PackOptions = { + allowRotations: false, + gap: 0, + precision: 0, + }; + + expect(packer.pack(bin, rects, options)).toEqual({ + placements: [ + expect.objectContaining({ + data: '1', + left: 0, + bottom: 0, + }), + expect.objectContaining({ + data: '2', + left: 5, + bottom: 0, + }), + expect.objectContaining({ + data: '3', + left: 5, + bottom: 4, + }), + expect.objectContaining({ + data: '4', + left: 0, + bottom: 5, + }), + ], + leftovers: ['5'], + }); + }); + + it('should not pack rectangles of the same size ontop of one another', () => { + const visualizer = defineSvgVisualizer( + 'src/packers/__tests__/dist/TightPacker/2', + ); + const packer = createTightPacker(visualizer); + const bin = new Rectangle(null, 0, 0, 10, 5); + const rects = [ + new Rectangle('1', 0, 0, 5, 5), + new Rectangle('2', 0, 0, 5, 5), + new Rectangle('3', 0, 0, 5, 5), + ]; + const options: PackOptions = { + allowRotations: false, + gap: 0, + precision: 0, + }; + + expect(packer.pack(bin, rects, options)).toEqual({ + placements: [ + expect.objectContaining({ + data: '1', + left: 0, + bottom: 0, + }), + expect.objectContaining({ + data: '2', + left: 5, + bottom: 0, + }), + ], + leftovers: ['3'], + }); + }); + + it('should allow rotating rectangles to fit in either orientation', () => { + const visualizer = defineSvgVisualizer( + 'src/packers/__tests__/dist/TightPacker/3', + ); + const packer = createTightPacker(visualizer); + const bin = new Rectangle(null, 0, 0, 1, 3); + const rects = [ + new Rectangle('1', 0, 0, 1, 1), + new Rectangle('2', 0, 0, 2, 1), + ]; + const options: PackOptions = { + allowRotations: true, + gap: 0, + precision: 0, + }; + + expect(packer.pack(bin, rects, options)).toEqual({ + placements: [ + expect.objectContaining({ + data: '1', + left: 0, + bottom: 0, + }), + expect.objectContaining({ + data: '2', + left: 0, + bottom: 1, + height: 2, + width: 1, + }), + ], + leftovers: [], + }); + }); +}); diff --git a/npm/src/packers/index.ts b/npm/src/packers/index.ts new file mode 100644 index 0000000..c1bd00a --- /dev/null +++ b/npm/src/packers/index.ts @@ -0,0 +1,4 @@ +export * from './Packer'; +export * from './GenericPacker'; +export * from './TightPacker'; +export * from './CutPacker'; diff --git a/npm/src/packers/utils.ts b/npm/src/packers/utils.ts new file mode 100644 index 0000000..97aecc7 --- /dev/null +++ b/npm/src/packers/utils.ts @@ -0,0 +1,25 @@ +import type { Point, Rectangle } from '../geometry'; + +export function getAllPossiblePlacements( + bin: Rectangle, + placements: Rectangle[], + gap: number, +): Point[] { + return [ + bin.bottomLeft, + ...placements.map((rect) => rect.topLeft.add(0, gap)), + ...placements.map((rect) => rect.bottomRight.add(gap, 0)), + ]; +} + +export function isValidPlacement( + bin: Rectangle, + placements: Rectangle[], + rect: Rectangle, + precision: number, +): boolean { + return ( + rect.isInside(bin, precision) && + placements.every((p) => !rect.isIntersecting(p, precision)) + ); +} diff --git a/npm/src/types.ts b/npm/src/types.ts index dc85da9..daa1812 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import type { Rectangle } from './geometry'; /** * A number in meters or a string with unit suffix ("1in"). @@ -84,9 +85,13 @@ export const Config = z.object({ * Extra padding to add to the top and right sides of the boards/stock. */ extraSpace: Distance.default('0'), + precision: z.number().default(1e-5), }); export type Config = z.infer; +/** + * JSON friendly object containing boards and part placements. + */ export interface BoardLayout { stock: BoardLayoutStock; placements: BoardLayoutPlacement[]; @@ -110,10 +115,18 @@ export interface BoardLayoutLeftover { } export interface BoardLayoutPlacement extends BoardLayoutLeftover { - xM: number; - yM: number; leftM: number; rightM: number; topM: number; bottomM: number; } + +/** + * Intermediate type for storing the board layout with the rectangle class. Not + * JSON friendly. This gets converted into `BoardLayout`, which doesn't contain + * any classes, and is save to convert to and from JSON. + */ +export interface PotentialBoardLayout { + stock: Stock; + placements: Rectangle[]; +} diff --git a/npm/src/__tests__/units.test.ts b/npm/src/utils/__tests__/units.test.ts similarity index 100% rename from npm/src/__tests__/units.test.ts rename to npm/src/utils/__tests__/units.test.ts diff --git a/npm/src/utils/floating-point-utils.ts b/npm/src/utils/floating-point-utils.ts new file mode 100644 index 0000000..f9454a1 --- /dev/null +++ b/npm/src/utils/floating-point-utils.ts @@ -0,0 +1,50 @@ +/** + * Copied from + */ +export function isNearlyEqual(a: number, b: number, epsilon: number): boolean { + if (a === b) return true; + + const absA = Math.abs(a); + const absB = Math.abs(b); + const diff = Math.abs(a - b); + + if (a === 0 || b === 0 || absA + absB < Number.MIN_VALUE) { + return diff < epsilon * Number.MIN_VALUE; + } else { + // In JavaScript, Number.MAX_VALUE is used as it represents the maximum + // representable positive number, similar to Float.MAX_VALUE in Java for floating-point literals + return diff / Math.min(absA + absB, Number.MAX_VALUE) < epsilon; + } +} + +export function isNearlyGreaterThan( + a: number, + b: number, + epsilon: number, +): boolean { + return a + epsilon > b; +} + +export function isNearlyLessThan( + a: number, + b: number, + epsilon: number, +): boolean { + return a - epsilon < b; +} + +export function isNearlyGreaterThanOrEqual( + a: number, + b: number, + epsilon: number, +): boolean { + return isNearlyEqual(a, b, epsilon) || isNearlyGreaterThan(a, b, epsilon); +} + +export function isNearlyLessThanOrEqual( + a: number, + b: number, + epsilon: number, +): boolean { + return isNearlyEqual(a, b, epsilon) || isNearlyLessThan(a, b, epsilon); +} diff --git a/npm/src/utils/stock-utils.ts b/npm/src/utils/stock-utils.ts new file mode 100644 index 0000000..abfb5c2 --- /dev/null +++ b/npm/src/utils/stock-utils.ts @@ -0,0 +1,16 @@ +import { isNearlyEqual } from './floating-point-utils'; +import type { BoardLayoutStock, PartToCut, Stock } from '../types'; + +export function isValidStock( + test: Stock | BoardLayoutStock, + target: PartToCut | Stock, + epsilon: number, +) { + return ( + isNearlyEqual( + 'size' in target ? target.size.thickness : target.thickness, + 'thicknessM' in test ? test.thicknessM : test.thickness, + epsilon, + ) && test.material === target.material + ); +} diff --git a/npm/src/units.ts b/npm/src/utils/units.ts similarity index 100% rename from npm/src/units.ts rename to npm/src/utils/units.ts diff --git a/npm/src/visualizers/LogVisualizer.ts b/npm/src/visualizers/LogVisualizer.ts new file mode 100644 index 0000000..2edfb86 --- /dev/null +++ b/npm/src/visualizers/LogVisualizer.ts @@ -0,0 +1,9 @@ +import type { Visualizer } from './Visualizer'; + +export function defineLogVisualizer(): Visualizer { + return { + async render(description, options) { + console.log(description, options); + }, + }; +} diff --git a/npm/src/visualizers/SvgVisualizer.ts b/npm/src/visualizers/SvgVisualizer.ts new file mode 100644 index 0000000..b7ec8d8 --- /dev/null +++ b/npm/src/visualizers/SvgVisualizer.ts @@ -0,0 +1,72 @@ +import type { Visualizer } from './Visualizer'; +import { join } from 'node:path'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { Rectangle, type Point } from '../geometry'; + +const partStyle = { fill: 'gray', stroke: 'black', strokeWidth: 1 }; +const stockStyle = { fill: 'DarkGray', stroke: 'DimGray', strokeWidth: 1 }; +const pointStyle = { radius: 2, fill: 'red' }; +const possiblePlacementStyle = { stroke: 'red', strokeWidth: 1 }; +const validPlacementStyle = { stroke: 'green', strokeWidth: 1 }; + +/** + * Used in tests to visualize and debug bin packing issues. + */ +export function defineSvgVisualizer(outDir: string): Visualizer { + let i = 0; + + return { + async render(description, options) { + i++; + const toPlace = options.toPlace?.translate(options.bin.width * 1.2, 0); + + let bounds = new Rectangle(null, 0, 0, 0, 0); + [ + toPlace, + options.bin, + ...options.res.placements, + ...(options.validPlacements ?? []), + ].forEach((rect) => { + if (rect) bounds = bounds.swallow(rect); + }); + const scale = 250 / Math.max(bounds.width, bounds.height); + + const getSvgRect = ( + rect: Rectangle, + style: { fill?: string; stroke?: string; strokeWidth?: number }, + ) => + ``; + + const getSvgCircle = ( + center: Point, + style: { radius: number; fill?: string }, + ) => + ``; + + const svg = [ + ``, + ]; + svg.push(getSvgRect(options.bin, stockStyle)); + options.res.placements.forEach((p) => { + svg.push(getSvgRect(p, partStyle)); + }); + if (toPlace) svg.push(getSvgRect(toPlace, partStyle)); + + options.possiblePlacements?.forEach((p) => { + svg.push(getSvgRect(p, possiblePlacementStyle)); + }); + options.validPlacements?.forEach((p) => { + svg.push(getSvgRect(p, validPlacementStyle)); + }); + options.possiblePoints?.forEach((p) => { + svg.push(getSvgCircle(p, pointStyle)); + }); + + svg.push(''); + + mkdirSync(outDir, { recursive: true }); + const file = join(outDir, `${i}-${description}.svg`); + writeFileSync(file, svg.join('\n')); + }, + }; +} diff --git a/npm/src/visualizers/Visualizer.ts b/npm/src/visualizers/Visualizer.ts new file mode 100644 index 0000000..f11c5a3 --- /dev/null +++ b/npm/src/visualizers/Visualizer.ts @@ -0,0 +1,15 @@ +import type { Point, Rectangle } from '../geometry'; +import type { PackResult } from '../packers'; + +export interface Visualizer { + render(description: string, options: RenderOptions): void; +} + +export interface RenderOptions { + bin: Rectangle; + res: PackResult; + toPlace?: Rectangle; + possiblePoints?: Point[]; + possiblePlacements?: Rectangle[]; + validPlacements?: Rectangle[]; +} diff --git a/npm/src/visualizers/index.ts b/npm/src/visualizers/index.ts new file mode 100644 index 0000000..99f309c --- /dev/null +++ b/npm/src/visualizers/index.ts @@ -0,0 +1,3 @@ +export * from './Visualizer'; +export * from './LogVisualizer'; +export * from './SvgVisualizer'; diff --git a/package.json b/package.json index 0eb37ed..a405e6f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ ], "scripts": { "dev": "bun run build && bun --cwd web dev", + "test": "bun --cwd npm test", + "test:watch": "bun --cwd npm test:watch", "build": "bun --cwd npm build", "check": "bun --cwd npm check && bun --cwd web check", "prepare": "simple-git-hooks" diff --git a/web/components/CutlistPreview.vue b/web/components/CutlistPreview.vue index 633ae67..8966631 100644 --- a/web/components/CutlistPreview.vue +++ b/web/components/CutlistPreview.vue @@ -9,7 +9,7 @@ const { scale, resetZoom, zoomIn, zoomOut } = usePanZoom(container);

{{ error }}

diff --git a/web/composables/useBoardLayoutsQuery.ts b/web/composables/useBoardLayoutsQuery.ts index 9972b6d..9623f1e 100644 --- a/web/composables/useBoardLayoutsQuery.ts +++ b/web/composables/useBoardLayoutsQuery.ts @@ -17,7 +17,7 @@ export default function () { const parts = partsQuery.data.value; if (parts == null) return undefined; - return generateBoardLayouts(parts, stock.value, config.value); + return generateBoardLayouts(toRaw(parts), stock.value, config.value); }); return { diff --git a/web/composables/useCutlistConfig.ts b/web/composables/useCutlistConfig.ts index 457bec6..b91a0df 100644 --- a/web/composables/useCutlistConfig.ts +++ b/web/composables/useCutlistConfig.ts @@ -9,5 +9,6 @@ export default createSharedComposable(() => { bladeWidth: bladeWidth.value, optimize: optimize.value, extraSpace: extraSpace.value, + precision: 1e-5, })); }); diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index ebb34cc..e9d484c 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -4,6 +4,9 @@ import { resolve } from 'node:path'; export default defineNuxtConfig({ modules: ['@nuxt/ui', '@vueuse/nuxt'], ssr: true, // SSG + alias: { + '@aklinker1/cutlist': resolve('../npm/src'), + }, runtimeConfig: { onshape: { accessKey: '', @@ -15,7 +18,7 @@ export default defineNuxtConfig({ }, app: { head: { - title: 'Onshape Cutlist Generator', + title: 'Cutlist Generator', htmlAttrs: { lang: 'en', }, diff --git a/web/package.json b/web/package.json index f6f5611..6aeb0b6 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,6 @@ "postinstall": "nuxt prepare" }, "dependencies": { - "@aklinker1/cutlist": "workspace:*", "@tanstack/vue-query": "^5.28.4", "js-yaml": "^4.1.0" }, diff --git a/web/tsconfig.json b/web/tsconfig.json index 4b34df1..6195436 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,3 +1,6 @@ { - "extends": "./.nuxt/tsconfig.json" + "extends": "./.nuxt/tsconfig.json", + "compilerOptions": { + "types": ["@types/bun"] + } }