diff --git a/bun.lockb b/bun.lockb index 7342672..85562e1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/npm/src/geometry.ts b/npm/src/geometry.ts index 85cb594..56cd4a5 100644 --- a/npm/src/geometry.ts +++ b/npm/src/geometry.ts @@ -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 { + /** + * In meters + */ x: number; + /** + * In meters + */ y: number; + /** + * In meters + */ width: number; + /** + * In meters + */ height: number; constructor( @@ -111,12 +123,19 @@ export class Rectangle { } export interface Point { + /** + * In meters + */ x: number; + /** + * In meters + */ y: number; } -export class BoardLayout { +export class BoardLayouter { readonly placements: Rectangle[] = []; + constructor( readonly stock: Rectangle, readonly config: Config, @@ -241,13 +260,13 @@ export class BoardLayout { }); } - reduceStock(allStock: Rectangle[]): BoardLayout { + reduceStock(allStock: Rectangle[]): 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); }); @@ -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, + })), + }; + } } diff --git a/npm/src/index.ts b/npm/src/index.ts index 34f8d7d..7b411aa 100644 --- a/npm/src/index.ts +++ b/npm/src/index.ts @@ -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, -) { - 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, -) { return { - getPartsToCut: async (project: Project): Promise => { - 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((_, 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) => diff --git a/npm/src/onshape.ts b/npm/src/onshape.ts index 80cb9b6..1cfca69 100644 --- a/npm/src/onshape.ts +++ b/npm/src/onshape.ts @@ -1,16 +1,106 @@ +import type { PartToCut } from './types'; +import type { $Fetch } from 'ofetch'; import { createFetch } from 'ofetch'; import * as base64 from 'base64-js'; import consola from 'consola'; +import { p } from '@antfu/utils'; -export function defineOnshapeApi(config: { - baseUrl?: string; - auth?: { - accessKey: string; - secretKey: string; +export interface OnshapeLoader { + getParts(url: string): Promise; + getParts(ids: OnshapeProjectIds): Promise; + getDocument(did: string): Promise; + fetch: $Fetch; +} + +export function defineOnshapeLoader(config?: OnshapeApiConfig): OnshapeLoader { + const api = defineOnshapeApi(config); + + const getIds = (arg0: string | OnshapeProjectIds): OnshapeProjectIds => + typeof arg0 === 'string' ? parseOnshapeUrl(arg0) : arg0; + + const getBom = async (ids: OnshapeProjectIds) => { + if (ids.wvmid == null) { + const document = await api.getDocument(ids.did); + ids.wvmid = document.defaultWorkspace.id; + } + return await api.getAssemblyBom(ids.did, ids.wvmid, ids.eid); + }; + + const getPartsToCut = async (bom: Onshape.Bom): Promise => { + 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 api.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((_, i) => ({ + name: info.name, + partNumber: infoI + 1, + instanceNumber: i + 1, + size: info.size, + material: info.material, + })), + ).promise; + const parts = partGroups.flat(); + consola.info('Total parts:', parts.length); + return parts.flat(); + }; + + return { + fetch: api.fetch, + getParts: async (arg0) => { + const ids = getIds(arg0); + const bom = await getBom(ids); + return await getPartsToCut(bom); + }, + getDocument: async (did) => api.getDocument(did), }; -}) { +} + +function defineOnshapeApi(config?: OnshapeApiConfig) { const getAuthHeaders = () => { - if (config.auth == null) return undefined; + if (config?.auth == null) return undefined; const encoded = base64.fromByteArray( Uint8Array.from( @@ -25,7 +115,7 @@ export function defineOnshapeApi(config: { }; const fetch = createFetch({ defaults: { - baseURL: config.baseUrl ?? 'https://cad.onshape.com/api/v6', + baseURL: config?.baseUrl ?? 'https://cad.onshape.com/api/v6', headers: getAuthHeaders(), onResponseError(context) { consola.error(context.response._data); @@ -59,15 +149,16 @@ export function defineOnshapeApi(config: { }; } -export type OnshapeApiClient = ReturnType; - -export namespace Onshape { +namespace Onshape { export interface Document { id: string; name: string; thumbnail: { href: string; }; + owner: { + name: string; + }; defaultWorkspace: { id: string; name: string; @@ -108,3 +199,69 @@ export namespace Onshape { lowX: number; } } + +/** + * Return the project IDs based on a URL, or throw an error if invalid. + */ +export function parseOnshapeUrl(url: string): OnshapeProjectIds { + const path = new URL(url).pathname; + const matches = + /^\/documents\/(?.*?)\/.*?\/(?.*?)\/e\/(?.*?)$/.exec(path); + if (matches?.groups == null) + throw Error('Onshape URL does not have a valid path: ' + path); + + return { + did: matches.groups.did, + wvmid: matches.groups.wvmid, + eid: matches.groups.eid, + }; +} + +/** + * Apart of the project's URL when opened in your browser: + * ``` + * https://cad.onshape.com/documents/{did}/w/{wvmid}/e/{eid} + * ``` + */ +export interface OnshapeProjectIds { + /** + * Apart of the project's URL when opened in your browser: + * ``` + * https://cad.onshape.com/documents/{did}/w/{wvmid}/e/{eid} + * ``` + */ + did: string; + /** + * Apart of the project's URL when opened in your browser: + * ``` + * https://cad.onshape.com/documents/{did}/w/{wvmid}/e/{eid} + * ``` + */ + wvmid?: string; + /** + * Apart of the project's URL when opened in your browser: + * ``` + * https://cad.onshape.com/documents/{did}/w/{wvmid}/e/{eid} + * ``` + */ + eid: string; +} + +/** + * Create or get from . + */ +export interface OnshapeAuth { + /** + * Create or get from . + */ + accessKey: string; + /** + * Create or get from . + */ + secretKey: string; +} + +export interface OnshapeApiConfig { + baseUrl?: string; + auth?: OnshapeAuth; +} diff --git a/npm/src/types.ts b/npm/src/types.ts index 2ec69ce..ab060d5 100644 --- a/npm/src/types.ts +++ b/npm/src/types.ts @@ -1,21 +1,37 @@ import { z } from 'zod'; -export const ProjectSource = z.object({ - type: z.literal('onshape'), - id: z.string(), - assemblyId: z.string().optional(), -}); -export type ProjectSource = z.infer; - +/** + * A number in meters or a string with unit suffix ("1in"). + */ const Distance = z.union([z.number(), z.string()]); +type Distance = z.infer; +/** + * Contains the material and dimensions for a single panel or board. + */ export interface Stock { + /** + * The material name, matching what is set in Onshape. + */ material: string; + /** + * In meters + */ thickness: number; + /** + * In meters + */ width: number; + /** + * In meters + */ length: number; } +/** + * For a material, define a combination of widths, lengths, and thicknesses + * that can be combined to form multiple stocks. + */ export const StockMatrix = z.object({ material: z.string(), thickness: z.array(Distance), @@ -24,25 +40,76 @@ export const StockMatrix = z.object({ }); export type StockMatrix = z.infer; -export const Project = z.object({ - source: ProjectSource, -}); -export type Project = z.infer; - +/** + * Part info, material, and size. Everything needed to know how to layout the board on stock. + */ export interface PartToCut { partNumber: number; instanceNumber: number; name: string; material: string; size: { + /** + * In meters + */ width: number; + /** + * In meters + */ length: number; + /** + * In meters + */ thickness: number; }; } +/** + * Options for generating the board layouts. + */ export const Config = z.object({ + /** + * The blade kerf, usually around 0.125 inches. + */ bladeWidth: Distance.default('0.125in'), + /** + * The optimization method when laying out the parts on the stock. + * - `"space"`: Pack as many parts onto each peice of stock as possible + * - `"cuts"`: Generate board layouts optimizing for a minimal number of + * cuts. This usually results in stacking peices with the same width in a + * column, making it easier to cut out. + */ optimize: z.union([z.literal('space'), z.literal('cuts')]).default('cuts'), }); export type Config = z.infer; + +export interface BoardLayout { + stock: BoardLayoutStock; + placements: BoardLayoutPlacement[]; +} + +export interface BoardLayoutStock { + material: string; + widthM: number; + lengthM: number; + thicknessM: number; +} + +export interface BoardLayoutLeftover { + partNumber: number; + instanceNumber: number; + name: string; + material: string; + widthM: number; + lengthM: number; + thicknessM: number; +} + +export interface BoardLayoutPlacement extends BoardLayoutLeftover { + xM: number; + yM: number; + leftM: number; + rightM: number; + topM: number; + bottomM: number; +} diff --git a/package.json b/package.json index 062c030..571fc0f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@aklinker1/check": "^1.3.1", - "standard-version": "^9.5.0" + "standard-version": "^9.5.0", + "vue-tsc": "^2.0.10" } }