Skip to content

Commit

Permalink
feat!: Reorganize NPM package
Browse files Browse the repository at this point in the history
  • Loading branch information
aklinker1 committed Apr 4, 2024
1 parent e7e514a commit 1e61e35
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 187 deletions.
Binary file modified bun.lockb
Binary file not shown.
53 changes: 49 additions & 4 deletions npm/src/geometry.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import type { PartToCut, Stock, Config } from './types';
import type { PartToCut, Stock, Config, BoardLayout } from './types';
import { Distance } from './units';

export class Rectangle<TData> {
/**
* In meters
*/
x: number;
/**
* In meters
*/
y: number;
/**
* In meters
*/
width: number;
/**
* In meters
*/
height: number;

constructor(
Expand Down Expand Up @@ -111,12 +123,19 @@ export class Rectangle<TData> {
}

export interface Point {
/**
* In meters
*/
x: number;
/**
* In meters
*/
y: number;
}

export class BoardLayout {
export class BoardLayouter {
readonly placements: Rectangle<PartToCut>[] = [];

constructor(
readonly stock: Rectangle<Stock>,
readonly config: Config,
Expand Down Expand Up @@ -241,13 +260,13 @@ export class BoardLayout {
});
}

reduceStock(allStock: Rectangle<Stock>[]): BoardLayout {
reduceStock(allStock: Rectangle<Stock>[]): BoardLayouter {
const validStock = allStock.filter(
(stock) => stock.data.material === this.stock.data.material,
);
const validLayouts = validStock
.map((stock) => {
const layout = new BoardLayout(stock, this.config);
const layout = new BoardLayouter(stock, this.config);
this.placements.forEach(({ data: part }) => {
layout.tryAddPart(part);
});
Expand All @@ -257,4 +276,30 @@ export class BoardLayout {
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,
})),
};
}
}
234 changes: 75 additions & 159 deletions npm/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,175 +1,91 @@
import type { OnshapeApiClient } from './onshape';
import type { PartToCut, Project, Stock, StockMatrix, Config } from './types';
import type {
PartToCut,
Stock,
StockMatrix,
Config,
BoardLayout,
BoardLayoutLeftover,
} from './types';
import consola from 'consola';
import { p } from '@antfu/utils';
import { BoardLayout, Rectangle } from './geometry';
import { BoardLayouter, Rectangle } from './geometry';
import { Distance } from './units';

export * from './types';
export * from './units';

export async function getBoardLayouts(
onshape: OnshapeApiClient,
project: Project,
/**
* 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.
*/
export function generateBoardLayouts(
parts: PartToCut[],
stock: StockMatrix[],
config: Config,
debugObject?: (name: string, object: any) => Promise<void> | void,
) {
const generator = createCutlistGenerator(onshape, config, debugObject);
const parts = await generator.getPartsToCut(project);
return await generator.generateBoardLayouts(parts, stock);
}
): {
layouts: BoardLayout[];
leftovers: BoardLayoutLeftover[];
} {
consola.info('Generating board layouts...');

// 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(
(a, b) => b.size.width * b.size.length - b.size.width * a.size.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;
}

const matchingStock = boards.find(
(stock) => stock.data.material === part.material,
);
if (matchingStock == null) {
consola.warn('Not stock found for ' + part.material);
leftovers.push(part);
continue;
}

const newLayout = new BoardLayouter(matchingStock, config);
const addedToNew = newLayout.tryAddPart(part);
if (addedToNew) {
layouts.push(newLayout);
} else {
leftovers.push(part);
}
}

export function createCutlistGenerator(
onshape: OnshapeApiClient,
config: Config,
debugObject?: (name: string, object: any) => Promise<void> | void,
) {
return {
getPartsToCut: async (project: Project): Promise<PartToCut[]> => {
const did = project.source.id;

consola.info('Getting document info...');
const document = await onshape.getDocument(did);
await debugObject?.('document', document);
const wvmid = document.defaultWorkspace.id;

let eid = project.source.assemblyId;
if (!eid) {
consola.info('Assembly ID not provided, finding first assembly...');
const assemblies = await onshape.getAssemblies(did, wvmid);
await debugObject?.('assemblies', assemblies);
consola.log(`Assemblies found: ${assemblies.length}`);
if (assemblies.length === 0) {
throw Error(`No assemblies found for ${document.name}`);
}
consola.info(`Using "${assemblies[0].name}"`);
eid = assemblies[0].id;
}

const bom = await onshape.getAssemblyBom(did, wvmid, eid);
await debugObject?.('bom', bom);

const quantityHeaderId = bom.headers.find(
(header) => header.propertyName === 'quantity',
)?.id;
if (quantityHeaderId == null) {
consola.log('Headers:', bom.headers);
throw Error('Could not find quantity column in BOM');
}

const nameHeaderId = bom.headers.find(
(header) => header.propertyName === 'name',
)?.id;
if (nameHeaderId == null) {
consola.log('Headers:', bom.headers);
throw Error('Could not find name column in BOM');
}

const materialHeaderId = bom.headers.find(
(header) => header.propertyName === 'material',
)?.id;
if (materialHeaderId == null) {
consola.log('Headers:', bom.headers);
throw Error('Could not find material column in BOM');
}

consola.info(`Loading part details: ${bom.rows.length}`);
const partGroups = await p(bom.rows)
.map(async ({ itemSource, headerIdToValue }) => {
const bounds = await onshape.getPartBoundingBox(
itemSource.documentId,
itemSource.wvmType,
itemSource.wvmId,
itemSource.elementId,
itemSource.partId,
);
const material = headerIdToValue[materialHeaderId] as any;
return {
size: {
width: bounds.highY - bounds.lowY,
length: bounds.highX - bounds.lowX,
thickness: bounds.highZ - bounds.lowZ,
},
quantity: Number(headerIdToValue[quantityHeaderId]),
name: String(headerIdToValue[nameHeaderId]),
material: material?.displayName ?? 'Unknown',
};
})
.map((info, infoI) =>
Array.from({ length: info.quantity }).map<PartToCut>((_, i) => ({
name: info.name,
partNumber: infoI + 1,
instanceNumber: i + 1,
size: info.size,
material: info.material,
})),
).promise;
const parts = partGroups.flat();
await debugObject?.('parts', parts);
consola.info('Total parts:', parts.length);
return parts.flat();
},

generateBoardLayouts: async (
parts: PartToCut[],
availableStock: StockMatrix[],
): Promise<{ layouts: BoardLayout[]; leftovers: PartToCut[] }> => {
consola.info('Generating board layouts...');

// Create geometry for stock and parts
const stockRectangles = reduceStockMatrix(availableStock)
.map((stock) => new Rectangle(stock, 0, 0, stock.width, stock.length))
.toSorted((a, b) => b.area - a.area);
await debugObject?.('stock-rectangles', stockRectangles);
if (stockRectangles.length === 0) {
throw Error('You must include at least 1 stock.');
}

// Generate the layouts
const partQueue = [...parts].sort(
(a, b) => b.size.width * b.size.length - b.size.width * a.size.length,
);
const leftovers: PartToCut[] = [];
const layouts: BoardLayout[] = [];
while (partQueue.length > 0) {
const part = partQueue.shift()!;
const addedToExisting = layouts.find((layout) =>
layout.tryAddPart(part),
);
if (addedToExisting) {
continue;
}

const matchingStock = stockRectangles.find(
(stock) => stock.data.material === part.material,
);
if (matchingStock == null) {
consola.warn('Not stock found for ' + part.material);
leftovers.push(part);
continue;
}

const newLayout = new BoardLayout(matchingStock, config);
const addedToNew = newLayout.tryAddPart(part);
if (addedToNew) {
layouts.push(newLayout);
} else {
leftovers.push(part);
}
}
debugObject?.('layouts', layouts);
debugObject?.('leftovers', leftovers);

const optimizedLayouts = layouts.map((layout) =>
layout.reduceStock(stockRectangles),
);

return { layouts: optimizedLayouts, leftovers };
},
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,
})),
};
}

/**
* Given a stock matrix, reduce it down to the individual boards available.
*/
export function reduceStockMatrix(matrix: StockMatrix[]): Stock[] {
return matrix.flatMap((item) =>
item.length.flatMap((length) =>
Expand Down
Loading

0 comments on commit 1e61e35

Please sign in to comment.