From 20e1d16e5cc5c7c253e2eb7fc2f3c615a93ed2ef Mon Sep 17 00:00:00 2001 From: Sushil Muthuvelkumar <123318620+sushilm20@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:18:20 -0400 Subject: [PATCH 1/3] DXF of each 2D-cut part's flat face in the build pack Reads the part's body details (one Onshape request; one more for tessellated edges only when it has a spline or ellipse) beside its STEP, and the browser draws the largest planar face as an R12 DXF in millimetres. Files sit beside the STEP in 2D_cut/, MANIFEST.txt lists each DXF with size and thickness or its failure, and the Make sheet gains a DXF file column. --- CLAUDE.md | 9 ++ apps/web/fixture-api.ts | 9 +- apps/web/src/app/Landing.tsx | 4 +- apps/web/src/app/Make.tsx | 4 +- apps/web/src/app/Policies.tsx | 4 +- apps/web/src/app/useControlKit.ts | 50 ++++-- apps/worker/src/dxf.test.ts | 78 +++++++++ apps/worker/src/index.test.ts | 37 ++++- apps/worker/src/index.ts | 12 +- apps/worker/src/mock-onshape.ts | 42 ++++- apps/worker/src/onshape-client.test.ts | 2 + apps/worker/src/onshape-client.ts | 4 +- apps/worker/src/step-export.test.ts | 90 ++++++++++- apps/worker/src/step-export.ts | 63 +++++++- packages/excel/src/step-zip.test.ts | 28 +++- packages/excel/src/step-zip.ts | 22 ++- packages/excel/src/workbook.test.ts | 9 ++ packages/excel/src/workbook.ts | 20 +-- packages/shared/src/dxf.ts | 211 +++++++++++++++++++++++++ packages/shared/src/index.ts | 6 +- packages/shared/src/step.ts | 22 ++- samples/FTC_Competition_Robot_STEP.zip | Bin 1177 -> 2234 bytes scripts/generate-sample-step.ts | 25 ++- tests/smoke.spec.ts | 20 ++- 24 files changed, 697 insertions(+), 74 deletions(-) create mode 100644 apps/worker/src/dxf.test.ts create mode 100644 packages/shared/src/dxf.ts diff --git a/CLAUDE.md b/CLAUDE.md index 21a75a8..96f80f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,6 +278,15 @@ REV feed schedule and catalog refresh (2026-10-28, owner request "vendor data ju - **In production.** The first cron cycle downloaded at 16:21 UTC and published at 17:50 UTC: one download, eight row steps, one publish, no retries, so no step was stopped. A second cycle forced at 21:22 UTC (state cleared by hand, one more GET of the feed) was traced with `wrangler tail`: Cloudflare's CPU time per run was 5 ms for the download, 8, 7, 9, 23, 11, 7, and 8 ms for the row steps, 14 ms for the final staging step, and 2 ms for an idle run; the tail dropped before the publish run, which published at 23:00 UTC. Every traced run ended `ok`, including the three above 10 ms, so the free plan did not stop them, but that margin is not guaranteed. Smaller steps barely help (50 rows took 3.0–5.6 ms in Node against 3.4–7.4 ms for 125) because most of a step is fixed per-invocation cost, and 20 steps would outlast the three-hour cutoff. If Cloudflare starts stopping steps, the cutoff ends the build and the live catalog stays. - **Slow re-crawl.** goBILDA, ServoCity, AndyMark, and Pitsco were re-crawled locally with `runVendor` (an untracked driver like PR 7c's: 400 pages per run, 500 ms between page requests, 1,000 ms between Shopify feed pages) and published with `catalog-load.ts` (`--expect` HORS SKUs, `--spot 20`, all 20/20): goBILDA 1,991 SKUs (16:13 UTC), ServoCity 4,318 (21:33 UTC, with Pack Info read from the page's own product: 4,287 verified packs), AndyMark 5,279 (was 5,313), Pitsco 1,625. Each kept its previous generation for a day of rollback. REV was not crawled. +DXF for flat parts (2026-10-28, owner request "do dxf for parts it detects as a flat piece"): +- **Endpoint choice.** The v9 OpenAPI (`cad.onshape.com/api/openapi`, 1.222) has no field in `BTTranslateFormatParams` that picks a face: its DXF options (`sheetMetalFlat`, `flatPatternAsync`, `dxfVersion`, bend-line flags) serve sheet-metal flat patterns, which FTC plates usually are not, and forum answers about face DXF through the translation API are unresolved. So Control Kit draws the DXF itself from the documented `GET /api/v9/parts/d/{did}/{w|v}/{wvid}/e/{eid}/partid/{pid}/bodydetails?configuration=…&includeGeometricData=true` (Part/getBodyDetails, OAuth2Read, synchronous): faces with `area`, plane `normal`/`origin`/`isOrientedWithFace`, loops of coedges, and edges with `curve` (LINE, CIRCLE with `radius`) and `geometry` (`startPoint`, `endPoint`, `midPoint`), in metres. Only when the text names a BCURVE, ELLIPSE, or ICURVE does it also read `GET …/partid/{pid}/tessellatededges?configuration=…` (Part/getEdges) for the whole part. Both use the STEP's own `exportSource` and configuration. +- **Cost.** One billable request per 2D-cut part (two with a spline or ellipse anywhere on it), on top of its STEP's start, polls, and download. Counted in the ledger as `body-details` and `tessellated-edges` under purpose `export`, inside the same per-poll budget (`pollBudget` 12); a DXF starts only with at least two requests left in the poll. No translation, so no polling. +- **Face.** `pickFlatFace` (`packages/shared/src/dxf.ts`) takes the solid body (the one whose id is the part id when there are several) and its largest planar face with an outer loop; a blind pocket or countersink shrinks its own side, so the through profile wins. `flatFaceDxf` draws it seen from outside the part (outward normal toward the viewer), x along the longest straight outer edge, origin at the lower-left corner, in mm: LINE, exact ARC (direction from the edge's `midPoint`, else the curve normal), CIRCLE for closed circular edges, and tessellated segments for anything else. R12 ASCII (`AC1009`) with `$INSUNITS` 4 (mm), everything on layer `CUT`, no TABLES section. Thickness is measured along the normal over the body's points, for the manifest. +- **Where it runs.** Parsing and drawing a 400-hole plate took 10–26 ms in Node, over the free plan's 10 ms with several plates per poll, so the Worker only fetches and stores Onshape's JSON (`export-file:{userId}:{jobId}:{index}:body` and `:edges`, 1-hour TTL; a text search, no parse) and serves it at `GET /api/exports/{jobId}/files/{index}.body|.edges`. The browser draws with `drawFlatPart` and fills sizes with `withDrawnDxf` before building the ZIP, as matching moved to the browser in PR 7f. +- **Job and files.** `ExportPart.dxf` (`DxfPart`: fileName, state, error, bytes, sizes, `edges`) exists only for `wantsDxf` lines (active cut-2d). A job is done only when no DXF is pending. A DXF failure is the DXF's alone: the STEP carries on, the app lists it as "{part} (DXF)", and MANIFEST.txt has a "DXF not made (the STEP file is unaffected)" section. The DXF sits beside the STEP in `2D_cut/` as `dxfFileName(stepFileName)`; a ZIP with only DXFs is still made. The manifest lists each DXF with its size and thickness. The workbook's Make sheet gains a "DXF file" column after "STEP file" (9 columns). `pnpm sample:step` now sets the fixture wheel to 2D cut, so the committed sample carries a DXF. +- **Mock.** `mockPlateBody` in `mock-onshape.ts` is a 100 × 60 × 3 mm plate (one fillet arc, a 10 mm hole, optional spline) shaped like `BTExportModelBodiesResponse`; vectors are read as `{x, y, z}` or arrays. +- **Not verified live** (no Onshape request was made): the real body details JSON shape (vector form, `midPoint` on arcs, `isOrientedWithFace`, `area` units), whether linked-document parts need `linkDocumentId`, the response size and Worker time for a large plate, and that the DXF opens at the right size in LightBurn, Inkscape, or a router's CAM. + ## Tuning, tests, and release Match normalization, thresholds, and alternative ranking are in `packages/shared/src/matching.ts`; an exact SKU still forces Buy absent a manual override. Disposition rules (`decideDisposition`), word lists, and print folders are in `packages/shared/src/classifier.ts`; order quantities are in `packages/shared/src/purchasing.ts`. Vendor stale hours and cron schedule are in `wrangler.toml`; catalog crawl batch limits are in `apps/worker/src/vendor-ingest.ts`. Scan request flow, source cache, and backoff are in `apps/worker/src/scan.ts`; BOM column mapping is in `packages/shared/src/bom.ts`; STEP export job bounds (per-call request budget, 5-minute translation timeout, 20 MB file cap) are in `apps/worker/src/step-export.ts`. Change these with fixture tests and record any changed data shape here. diff --git a/apps/web/fixture-api.ts b/apps/web/fixture-api.ts index a9573f7..d47884f 100644 --- a/apps/web/fixture-api.ts +++ b/apps/web/fixture-api.ts @@ -1,8 +1,8 @@ import { createHash, randomUUID } from 'node:crypto'; import { existsSync, readFileSync } from 'node:fs'; import type { Plugin } from 'vite'; -import { aggregateAssembly, classifyScan, mergeBom, wantsStep, type AssemblyDefinition, type CatalogHealth, type ScanResult } from '../../packages/shared/src/index'; -import { mockBom, mockStep } from '../worker/src/mock-onshape'; +import { aggregateAssembly, classifyScan, dxfFileName, mergeBom, wantsDxf, wantsStep, type AssemblyDefinition, type CatalogHealth, type ScanResult } from '../../packages/shared/src/index'; +import { mockBom, mockPlateBody, mockStep } from '../worker/src/mock-onshape'; /** The team's choices a saved scan already carries, so serving it keeps them as the Worker would. */ const savedChoices = (result: ScanResult) => Object.fromEntries(result.lines.flatMap(line => line.manualClassification ? [[line.bomLineKey, line.manualClassification]] : [])); @@ -116,8 +116,11 @@ export function fixtureApi(fixturePath?: string, catalogOrigin?: string, preview } const job = () => ({ jobId: '00000000-0000-4000-8000-0000000000bb', done: true, assemblyName: result.assemblyName, documentName: result.documentName, revision: null, scannedAt: result.scannedAt, parts: result.lines.filter(wantsStep).map((line, index) => ({ lineKey: line.bomLineKey, name: line.name, qty: line.qty, disposition: line.disposition, partId: line.partId, configuration: line.configuration, - sourceUrl: line.partStudioUrl, fileName: `part_${index}.step`, state: 'ready', error: null, units: 'mm', nameMatches: true, bytes: 400 })) }); + sourceUrl: line.partStudioUrl, fileName: `part_${index}.step`, state: 'ready', error: null, units: 'mm', nameMatches: true, bytes: 400, + ...(wantsDxf(line) ? { dxf: { fileName: dxfFileName(`part_${index}.step`), state: 'ready', error: null, bytes: null, widthMm: null, heightMm: null, thicknessMm: null, edges: false } } : {}) })) }); if (url.pathname === '/api/exports') return json(job()); + // A 2D-cut part's geometry is the mock plate; the app draws its DXF as it does from the Worker's. + if (/^\/api\/exports\/[\w-]+\/files\/\d+\.body$/.test(url.pathname)) return json(mockPlateBody('plate')); const file = url.pathname.match(/^\/api\/exports\/[\w-]+\/files\/(\d+)$/); if (file) { response.setHeader('content-type', 'application/step'); return response.end(mockStep(job().parts[Number(file[1])]?.name ?? 'part')); } if (/^\/api\/exports\/[\w-]+$/.test(url.pathname)) return json(job()); diff --git a/apps/web/src/app/Landing.tsx b/apps/web/src/app/Landing.tsx index 5e377fa..23983b1 100644 --- a/apps/web/src/app/Landing.tsx +++ b/apps/web/src/app/Landing.tsx @@ -16,7 +16,7 @@ const features: Array<{ icon: ReactNode; title: string; text: string; image: str { icon: , title: 'A buy list with real prices', image: 'buy-list', alt: 'Buy list grouped by vendor, with SKUs, quantities, packs to order, prices, and stock', text: 'Parts are matched by SKU against the goBILDA, REV, ServoCity, AndyMark, and Pitsco catalogs. Orders round up to whole packs, and a part is priced only when its match, pack size, and price are all verified, so the total never looks complete when it isn\'t.' }, { icon: , title: 'STEP files for everything you make', image: 'make', alt: 'Make list of 3D-printed and 2D-cut parts with a Download STEP files button', - text: 'Printed, CNC, and 2D-cut parts download as one ZIP of STEP files straight from Onshape, with a manifest of sources and quantities.' }, + text: 'Printed, CNC, and 2D-cut parts download as one ZIP of STEP files straight from Onshape, with a DXF cut file for every flat part and a manifest of sources and quantities.' }, { icon: , title: 'Every decision explained', image: 'part-drawer', alt: 'Part details for a goBILDA Yellow Jacket motor, with its Onshape picture and exact SKU match', text: 'Each part shows its picture from Onshape and why it was sorted the way it was, and one click changes it for the whole team. The Excel workbook carries the buy list with live totals, the make list, and links to every product and Part Studio.' }, ]; @@ -60,7 +60,7 @@ export function Landing() {
  • Control Kit only reads documents. It never edits your CAD.
  • Onshape sign-in tokens are stored encrypted on our server; your browser holds only an opaque session cookie.
  • -
  • To make rescans fast and cheap, Control Kit keeps, for you alone, the assembly and its bill of materials for 30 days per revision, your latest scan for a week, part pictures for 30 days, and exported STEP files for an hour.
  • +
  • To make rescans fast and cheap, Control Kit keeps, for you alone, the assembly and its bill of materials for 30 days per revision, your latest scan for a week, part pictures for 30 days, and exported STEP and DXF files for an hour.
  • Vendor prices come from the vendors' public catalogs. Totals are list prices before shipping, tax, or team discounts.
  • If AI review is turned on and you ask for it, the undecided parts' names, descriptions, part numbers, and candidate products, with a few details from the assembly, are sent to Google Gemini, or to Anthropic if Gemini is unavailable. Nothing is sent unless you click it.
diff --git a/apps/web/src/app/Make.tsx b/apps/web/src/app/Make.tsx index 71d70df..c4f40d2 100644 --- a/apps/web/src/app/Make.tsx +++ b/apps/web/src/app/Make.tsx @@ -21,7 +21,7 @@ export function Make({ kit, open, go }: { kit: ControlKit; open: (key: string) =
-

STEP files

For slicers and CAM. A 2D-cut part still needs a cut file (DXF) made from its flat face.

+

STEP files

For slicers and CAM. Each 2D-cut part also gets a DXF of its flat face, in millimetres, ready for a laser, router, or waterjet.

{step?.running &&
@@ -29,7 +29,7 @@ export function Make({ kit, open, go }: { kit: ControlKit; open: (key: string) =

{step.total ? `${step.ready} of ${step.total} exported${step.failed ? `, ${step.failed} failed` : ''}. Onshape translates each part; large parts take longer.` : 'Starting the export…'}

} {step && !step.running && step.zipMade !== null &&
- {step.zipMade ? {step.failures.length ? 'The parts below failed. They are also listed in MANIFEST.txt in the ZIP.' : 'Every part exported and passed its file check.'} + {step.zipMade ? {step.failures.length ? 'The parts below failed. They are also listed in MANIFEST.txt in the ZIP.' : 'Every part exported and passed its file check.'} : No ZIP was made. Each part and its reason is listed below.} {step.failures.length > 0 &&
{step.failures.map(failure =>
{failure.name}{failure.reason}
diff --git a/apps/web/src/app/Policies.tsx b/apps/web/src/app/Policies.tsx index 17480a7..53e51ce 100644 --- a/apps/web/src/app/Policies.tsx +++ b/apps/web/src/app/Policies.tsx @@ -40,14 +40,14 @@ function Privacy() {

What Control Kit reads

  • Your Onshape profile (the "read your profile" permission): your Onshape user id and name. The name shows who is signed in and who changed a part's type.
  • -
  • Your documents (the "read your documents" permission): only the assembly you open Control Kit beside, its bill of materials, part pictures, and the STEP files you ask to export.
  • +
  • Your documents (the "read your documents" permission): only the assembly you open Control Kit beside, its bill of materials, part pictures, and the STEP and DXF files you ask to export.

What it keeps, and for how long

  • Your Onshape sign-in tokens, encrypted on our server, for 30 days after you last use Control Kit. Your browser holds only a random session cookie. Signing out deletes them at once.
  • -
  • For you alone: the assembly and its bill of materials for 30 days per revision, your latest scan for a week, part pictures for 30 days, and exported STEP files for an hour. These make rescans fast and keep Onshape requests low.
  • +
  • For you alone: the assembly and its bill of materials for 30 days per revision, your latest scan for a week, part pictures for 30 days, and exported STEP and DXF files for an hour. These make rescans fast and keep Onshape requests low.
  • Your team number, if you enter one in Settings.
  • Your team's part choices, saved with the document for everyone who scans it, with the name of who made each change. The history of changes is kept for a year.
  • AI review's suggestions for 30 days, so asking again about the same parts gives the same answer without another AI call.
  • diff --git a/apps/web/src/app/useControlKit.ts b/apps/web/src/app/useControlKit.ts index e192325..06e162d 100644 --- a/apps/web/src/app/useControlKit.ts +++ b/apps/web/src/app/useControlKit.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { aiCandidateRecord, applyAiReview, applyClassificationChoice, applyMatchDecision, buildAiReviewRequest, classificationIdentity, classifyScan, decisionRecord, matchCatalog, type AiProvider, type AiReviewItem, type BomLine, type ClassificationOverride, type ControlPointChoiceTimes, type ExportPart, type MatchProgress, type ScanResult, type UserSettings, type VendorRecord } from '@control-kit/shared'; +import { aiCandidateRecord, applyAiReview, applyClassificationChoice, applyMatchDecision, buildAiReviewRequest, classificationIdentity, classifyScan, decisionRecord, drawFlatPart, matchCatalog, withDrawnDxf, type AiProvider, type AiReviewItem, type BomLine, type ClassificationOverride, type ControlPointChoiceTimes, type ExportPart, type MatchProgress, type ScanResult, type UserSettings, type VendorRecord } from '@control-kit/shared'; import { makeListKey } from './model'; /** Spreadsheet and ZIP builders load on first download, keeping them out of the initial bundle. */ const exporters = () => import('@control-kit/excel'); @@ -9,7 +9,9 @@ export type Session = { connected: boolean; setupPending?: boolean; user?: { id: export type ScanStep = 'revision' | 'assembly' | 'bom' | 'choices' | 'catalogs' | 'matching'; export type ScanCounts = { parts?: number; subassemblies?: number; bomRows?: number; cached?: boolean; lines?: number; catalogs?: number; catalogsTotal?: number; products?: number; matched?: number }; export type ScanProgress = { stage: string; current: number; total: number; instances: number; retry?: string; step?: ScanStep; counts?: ScanCounts }; -export type ExportState = { running: boolean; ready: number; failed: number; total: number; failures: { name: string; reason: string; url: string }[]; zipMade: boolean | null }; +export type ExportState = { running: boolean; ready: number; failed: number; total: number; failures: { name: string; reason: string; url: string }[]; zipMade: boolean | null; + /** DXF cut files for 2D-cut parts: how many arrived of how many were wanted. */ + dxfReady?: number; dxfTotal?: number }; type ScanEvent = | { type: 'progress'; stage: string; step?: ScanStep; current: number; total: number; instances: number; counts?: ScanCounts } | { type: 'rate-limit'; retryInMs: number } @@ -541,7 +543,7 @@ export function useControlKit(context: URLSearchParams) { let previous = ''; let stalled = false; while (!job.done) { - const signature = job.parts.map(part => part.state).join(); + const signature = job.parts.map(part => `${part.state}:${part.dxf?.state ?? ''}`).join(); // Waiting out an Onshape rate limit is progress, not a stuck job. if (signature !== previous || job.rateLimited) { previous = signature; lastChange = Date.now(); } setStep(current => current && { ...current, ready: job.parts.filter(part => part.state === 'ready').length, failed: job.parts.filter(part => part.state === 'failed').length, total: job.parts.length }); @@ -552,27 +554,49 @@ export function useControlKit(context: URLSearchParams) { if (!active()) return; } // A stalled export still delivers the files that finished; the rest are reported as unfinished. - if (stalled) job = { ...job, parts: job.parts.map(part => part.state === 'ready' || part.state === 'failed' ? part : { ...part, state: 'failed', error: 'Export stopped before this part finished. Export again to retry it.' }) }; + if (stalled) job = { ...job, parts: job.parts.map(part => { + const unfinished = 'Export stopped before this part finished. Export again to retry it.'; + const step = part.state === 'ready' || part.state === 'failed' ? part : { ...part, state: 'failed' as const, error: unfinished }; + return step.dxf?.state === 'pending' ? { ...step, dxf: { ...step.dxf, state: 'failed' as const, error: unfinished } } : step; + }) }; // One failed download fails only that part; the others are still zipped. const files: Record = {}; + const dxfFiles: Record = {}; for (const [index, part] of job.parts.entries()) { - if (part.state !== 'ready') continue; - try { const response = await api(`/api/exports/${job.jobId}/files/${index}`); if (response.ok) files[index] = await response.text(); } - catch { /* Reported below as "File was not downloaded". */ } - if (!active()) return; + if (part.state === 'ready') { + try { const response = await api(`/api/exports/${job.jobId}/files/${index}`); if (response.ok) files[index] = await response.text(); } + catch { /* Reported below as "File was not downloaded". */ } + if (!active()) return; + } + // A 2D-cut part's DXF is drawn here from the Onshape geometry the Worker stored; a failure never drops the STEP. + if (part.dxf?.state === 'ready') { + let drawn = null; + try { + const body = await api(`/api/exports/${job.jobId}/files/${index}.body`); + const edges = part.dxf.edges ? await api(`/api/exports/${job.jobId}/files/${index}.edges`) : null; + if (body.ok && (!edges || edges.ok)) drawn = drawFlatPart(await body.json(), edges ? await edges.json() : null, part.partId); + } catch { /* Reported below as "DXF was not downloaded". */ } + if (!active()) return; + job.parts[index] = withDrawnDxf(part, drawn); + if (drawn?.ok) dxfFiles[index] = drawn.text; + } } - const failures = job.parts.filter((part, index) => part.state === 'failed' || (part.state === 'ready' && files[index] === undefined)) + const stepFailures = job.parts.filter((part, index) => part.state === 'failed' || (part.state === 'ready' && files[index] === undefined)) .map(part => ({ name: part.name, reason: part.error ?? 'File was not downloaded', url: part.sourceUrl })); - const ready = job.parts.length - failures.length; + const dxfFailures = job.parts.filter((part, index) => part.dxf && (part.dxf.state === 'failed' || (part.dxf.state === 'ready' && dxfFiles[index] === undefined))) + .map(part => ({ name: `${part.name} (DXF)`, reason: part.dxf!.error ?? 'DXF was not downloaded', url: part.sourceUrl })); + const failures = [...stepFailures, ...dxfFailures]; + const ready = job.parts.length - stepFailures.length; // Failures are recorded before the ZIP is built, so an export where every part failed still lists each one. // The export counts as running, and the ZIP as not yet made, until the browser has been handed the file. - setStep({ running: true, ready, failed: failures.length, total: job.parts.length, failures, zipMade: null }); + const dxfTotal = job.parts.filter(part => part.dxf).length; + setStep({ running: true, ready, failed: stepFailures.length, total: job.parts.length, failures, zipMade: null, dxfReady: dxfTotal - dxfFailures.length, dxfTotal }); const { buildStepZip } = await exporters(); if (!active()) return; - const zip = buildStepZip({ ...job, files }); + const zip = buildStepZip({ ...job, files, dxfFiles }); saveFile(zip.bytes, zip.filename, 'application/zip'); lastStepZip.current = { scanId: result.scanId, makeList, name: zip.filename, bytes: zip.bytes }; - setStep(current => current && { ...current, running: false, zipMade: ready > 0 }); + setStep(current => current && { ...current, running: false, zipMade: ready + zip.dxf > 0 }); } catch (cause) { if (!active()) return; setStep(current => current && { ...current, running: false, zipMade: false }); diff --git a/apps/worker/src/dxf.test.ts b/apps/worker/src/dxf.test.ts new file mode 100644 index 0000000..6d75f25 --- /dev/null +++ b/apps/worker/src/dxf.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { dxfFileName, flatFaceDxf, pickFlatFace, tessellatedPoints, type BodyDetails } from '@control-kit/shared'; +import { mockPlateBody, mockPlateEdges } from './mock-onshape'; + +/** DXF group codes and values, paired. */ +const pairs = (text: string) => { const rows = text.trimEnd().split('\n'); return Array.from({ length: rows.length / 2 }, (_, index) => [rows[index * 2], rows[index * 2 + 1]] as const); }; +const entities = (text: string) => { + const list: Array<{ type: string; values: Record }> = []; + let inside = false; + for (const [code, value] of pairs(text)) { + if (code === '2' && value === 'ENTITIES') { inside = true; continue; } + if (!inside) continue; + if (code === '0') { if (value === 'ENDSEC') break; list.push({ type: value, values: {} }); continue; } + if (code !== '8') list[list.length - 1].values[code] = Number(value); + } + return list; +}; + +describe('DXF of a flat face', () => { + it('draws the largest planar face in millimetres, seen from outside, with lines, an arc, and a hole', () => { + const picked = pickFlatFace(mockPlateBody('plate'), 'plate'); + if (!picked.ok) throw new Error(picked.error); + expect(picked.face).toMatchObject({ faceId: 'bottom', needsTessellation: [], thicknessMm: 3 }); + const dxf = flatFaceDxf(picked.face); + if (!dxf.ok) throw new Error(dxf.error); + expect(dxf.text.startsWith('0\nSECTION\n2\nHEADER\n')).toBe(true); + expect(dxf.text.endsWith('0\nEOF\n')).toBe(true); + expect(dxf.text).toContain('$INSUNITS\n70\n4'); + expect(dxf).toMatchObject({ widthMm: 100, heightMm: 60, thicknessMm: 3, entities: 6 }); + const drawn = entities(dxf.text); + expect(drawn.map(entity => entity.type)).toEqual(['LINE', 'LINE', 'ARC', 'LINE', 'LINE', 'CIRCLE']); + // Seen from below, the plate's +y runs down the page: the hole at y = 30 of 60 stays in the middle, the + // rounded corner (top right from above) is at the bottom right. + expect(drawn[5].values).toMatchObject({ 10: 30, 20: 30, 40: 5 }); + expect(drawn[2].values).toMatchObject({ 10: 90, 20: 10, 40: 10 }); + // The arc's quarter turn from the right side to the bottom edge, counter-clockwise in DXF terms. + expect(drawn[2].values[50]).toBeCloseTo(270); + expect(drawn[2].values[51]).toBeCloseTo(0); + // Every line lies inside the drawing, which starts at the origin. + for (const entity of drawn.filter(item => item.type === 'LINE')) for (const code of ['10', '11', '20', '21']) expect(entity.values[code]).toBeGreaterThanOrEqual(0); + }); + + it('asks for tessellation only for edges that are neither lines nor circles, and draws them as segments', () => { + const picked = pickFlatFace(mockPlateBody('plate', true), 'plate'); + if (!picked.ok) throw new Error(picked.error); + expect(picked.face.needsTessellation).toEqual(['e4']); + expect(flatFaceDxf(picked.face)).toEqual({ ok: false, error: 'Onshape gave no shape for a bcurve edge of the flat face' }); + const dxf = flatFaceDxf(picked.face, tessellatedPoints(mockPlateEdges(['e4']))); + if (!dxf.ok) throw new Error(dxf.error); + expect(entities(dxf.text).filter(entity => entity.type === 'LINE')).toHaveLength(4 + 3); + expect(dxf.heightMm).toBeCloseTo(65); + }); + + it('reads vectors written as arrays too', () => { + const body = JSON.parse(JSON.stringify(mockPlateBody('plate')), (_key, value) => value && typeof value === 'object' && 'x' in value && 'y' in value && 'z' in value ? [value.x, value.y, value.z] : value) as BodyDetails; + const picked = pickFlatFace(body, 'plate'); + if (!picked.ok) throw new Error(picked.error); + expect(flatFaceDxf(picked.face)).toMatchObject({ ok: true, widthMm: 100, heightMm: 60 }); + }); + + it('explains a part it cannot draw', () => { + const noFace = mockPlateBody('plate'); + noFace.bodies[0].faces = noFace.bodies[0].faces.filter(face => face.surface.type !== 'PLANE'); + expect(pickFlatFace(noFace, 'plate')).toEqual({ ok: false, error: 'This part has no flat face to cut from' }); + expect(pickFlatFace({ bodies: [] }, 'plate')).toEqual({ ok: false, error: 'Onshape returned no solid body for this part' }); + const two = { bodies: [mockPlateBody('a').bodies[0], mockPlateBody('b').bodies[0]] }; + expect(pickFlatFace(two, 'c')).toEqual({ ok: false, error: 'Onshape returned several bodies for this part' }); + expect(pickFlatFace(two, 'b')).toMatchObject({ ok: true }); + const missing = mockPlateBody('plate'); + missing.bodies[0].edges = missing.bodies[0].edges.filter(edge => edge.id !== 'e6'); + expect(pickFlatFace(missing, 'plate')).toEqual({ ok: false, error: 'Onshape body details name an edge they do not describe' }); + }); + + it('names the DXF after its STEP file', () => { + expect(dxfFileName('Side_Plate__JHD.step')).toBe('Side_Plate__JHD.dxf'); + expect(dxfFileName('Side_Plate__JHD_2.step')).toBe('Side_Plate__JHD_2.dxf'); + }); +}); diff --git a/apps/worker/src/index.test.ts b/apps/worker/src/index.test.ts index 0d6fb0c..b602068 100644 --- a/apps/worker/src/index.test.ts +++ b/apps/worker/src/index.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; import worker, { cronVendors, decisionIdentity, type Env, type Kv } from './index'; import { createMockOnshape } from './mock-onshape'; -import { aggregateAssembly, classificationIdentity, matchCatalog, type ScanResult, type VendorId } from '@control-kit/shared'; +import { aggregateAssembly, classificationIdentity, drawFlatPart, matchCatalog, type ScanResult, type VendorId } from '@control-kit/shared'; import { clearCatalogMemoryCache } from './catalog'; import { batchKey, indexKey, pointerKey } from './vendor-ingest'; import { readFileSync } from 'node:fs'; @@ -385,6 +385,41 @@ describe('M5 settings and classification overrides against mock Onshape', () => const day = JSON.parse(env.APP_STATE.values.get(`onshape-ledger:${new Date().toISOString().slice(0, 10)}`)!); expect(day.byKind['translation-start']).toBeGreaterThanOrEqual(2); }); + it("reads a 2D-cut part's geometry beside its STEP and serves it from KV for the browser to draw its DXF", async () => { + const start = await call('/oauth/start?documentId=robot&workspaceOrVersion=w&workspaceOrVersionId=work1&elementId=elem1'); + const state = new URL(start.headers.get('location')!).searchParams.get('state')!; + const cookie = (await call(`/oauth/redirect?code=mock-code&state=${state}`)).headers.get('set-cookie')!.split(';')[0]; + const scan = await call('/api/scan?documentId=robot&workspaceOrVersion=w&workspaceOrVersionId=work1&elementId=elem1', cookie); + const result = (await scan.text()).trim().split('\n').map(row => JSON.parse(row)).find(event => event.type === 'result').result; + const plate = result.lines.find((line: { partId: string }) => line.partId === 'wheel-96'); + const override = await worker.fetch(new Request('http://localhost:8787/api/classification/override', { method: 'POST', headers: { cookie, origin: env.WEB_ORIGIN, 'content-type': 'application/json' }, + body: JSON.stringify({ documentId: 'robot', scanId: result.scanId, identity: classificationIdentity(plate), classification: 'cut-2d' }) }), env); + expect(override.status).toBe(200); + const day = () => JSON.parse(env.APP_STATE.values.get(`onshape-ledger:${new Date().toISOString().slice(0, 10)}`) ?? '{"byKind":{}}').byKind as Record; + const bodyDetailsBefore = day()['body-details'] ?? 0; + const { status, job } = await exportJob(cookie, 'robot', result.scanId); + expect(status).toBe(200); + expect(job.done).toBe(true); + const index = job.parts.findIndex(part => part.name === '96mm Wheel'); + const part = job.parts[index] as (typeof job.parts)[number] & { dxf?: { fileName: string; state: string; edges: boolean } }; + expect(part).toMatchObject({ state: 'ready', dxf: { fileName: part.fileName.replace(/\.step$/, '.dxf'), state: 'ready', edges: false } }); + // One body details request; the mock plate has only lines, an arc, and a circle, so no tessellation. + expect((day()['body-details'] ?? 0) - bodyDetailsBefore).toBe(1); + expect(day()['tessellated-edges']).toBeUndefined(); + const before = mock.apiRequests; + const file = await call(`/api/exports/${job.jobId}/files/${index}.body`, cookie); + expect(file.status).toBe(200); + expect(file.headers.get('content-type')).toBe('application/json'); + // The browser draws the DXF from it. + const drawn = drawFlatPart(await file.json(), null, 'wheel-96'); + expect(drawn).toMatchObject({ ok: true, widthMm: 100, heightMm: 60 }); + expect(drawn.ok && drawn.text.startsWith('0\nSECTION')).toBe(true); + expect(mock.apiRequests).toBe(before); + // No tessellated edges were needed, and a print has no geometry to serve. + expect((await call(`/api/exports/${job.jobId}/files/${index}.edges`, cookie)).status).toBe(404); + const guide = job.parts.findIndex(item => item.name === 'Custom Printed Intake Guide'); + expect((await call(`/api/exports/${job.jobId}/files/${guide}.body`, cookie)).status).toBe(404); + }); it('rejects a non-STEP download instead of zipping it, and refuses an export with nothing to make', async () => { const start = await call('/oauth/start?documentId=simple&workspaceOrVersion=w&workspaceOrVersionId=work1&elementId=elem1'); const state = new URL(start.headers.get('location')!).searchParams.get('state')!; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 2c9238e..87a0b56 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -6,7 +6,7 @@ import { runVendor } from './vendor-ingest'; import { catalogHealth } from './catalog'; import { aiReviewEnabled, cachedAiReview, releaseAiReview, reserveAiReview, reviewWithAi, trustedAiRequest, validAiRequest, type AiEnv } from './ai-review'; export { AiAllowance } from './ai-allowance'; -import { advanceExport, BudgetExhaustedError, createExportJob, fileKey, jobKey, jobSummary, pollBudget, type ExportJob, type RequestBudget } from './step-export'; +import { advanceExport, BudgetExhaustedError, createExportJob, dxfKey, fileKey, jobKey, jobSummary, pollBudget, type ExportJob, type RequestBudget } from './step-export'; import { countedFetch, persistLedger, RequestLedger } from './onshape-client'; import { assemblyNowKey, assemblyPreview, assemblyPreviewCacheKey, assemblyView, linePreview, previewCacheKey, PreviewUnavailableError, readAssemblyNow, redrawAssembly, reserveRedraw, type AssemblySource } from './preview'; import { clearCookie, readSignedEnvelope, seal, signedCookie, unseal, type SignedCookie } from './secrets'; @@ -483,19 +483,23 @@ async function route(request: Request, env: Env): Promise { throw error; } finally { await persistLedger(env.APP_STATE, ledger).catch(() => undefined); } } - const exportsMatch = url.pathname.match(/^\/api\/exports(?:\/([a-f0-9-]{36}))?(?:\/files\/(\d{1,4}))?$/); + const exportsMatch = url.pathname.match(/^\/api\/exports(?:\/([a-f0-9-]{36}))?(?:\/files\/(\d{1,4})(?:\.(body|edges))?)?$/); if (exportsMatch) { if (request.headers.get('origin') !== env.WEB_ORIGIN) return reply({ error: 'Invalid request origin.' }, 403); const current = await getSession(request, env); if (!current) return reply({ error: 'Reconnect Onshape.' }, 401); const userId = current.data.user.id; const [, jobId, fileIndex] = exportsMatch; - // A finished file is served from KV: downloading it again costs no Onshape request. + const geometry = exportsMatch[3] as 'body' | 'edges' | undefined; + // A finished file is served from KV: downloading it again costs no Onshape request. `.body` and `.edges` are the + // Onshape geometry a 2D-cut part's DXF is drawn from in the browser. if (jobId && fileIndex !== undefined && request.method === 'GET') { const raw = await env.APP_STATE.get(jobKey(userId, jobId)); const part = raw ? (JSON.parse(raw) as ExportJob).parts[Number(fileIndex)] : undefined; - const file = part?.state === 'ready' ? await env.APP_STATE.get(fileKey(userId, jobId, Number(fileIndex))) : null; + const ready = geometry ? part?.dxf?.state === 'ready' : part?.state === 'ready'; + const file = ready ? await env.APP_STATE.get(geometry ? dxfKey(userId, jobId, Number(fileIndex), geometry) : fileKey(userId, jobId, Number(fileIndex))) : null; if (!part || !file) return reply({ error: 'This file is not ready or has expired. Export again.' }, 404); + if (geometry) return new Response(file, { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } }); return new Response(file, { headers: { 'content-type': 'application/step', 'content-disposition': `attachment; filename="${part.fileName}"`, 'cache-control': 'no-store' } }); } let job: ExportJob; diff --git a/apps/worker/src/mock-onshape.ts b/apps/worker/src/mock-onshape.ts index b5dd1b6..2b55fc4 100644 --- a/apps/worker/src/mock-onshape.ts +++ b/apps/worker/src/mock-onshape.ts @@ -34,7 +34,7 @@ export function mockBom(definition: MockDefinition) { itemSource: { documentId: instance.documentId, elementId: instance.elementId, partId: instance.partId, wvmType: 'w', wvmId: 'work1', fullConfiguration: 'default', configuration: 'default', isStandardContent: false } })) }; } -const stepNames: Record = { 'intake-guide': 'Custom Printed Intake Guide', 'sensor-mount': 'Custom Printed Sensor Mount' }; +const stepNames: Record = { 'intake-guide': 'Custom Printed Intake Guide', 'sensor-mount': 'Custom Printed Sensor Mount', 'wheel-96': '96mm Wheel' }; /** A minimal but well-formed ISO-10303-21 file in millimetres, naming its product like Onshape's STEP export. */ export const mockStep = (name: string) => `ISO-10303-21; HEADER; @@ -51,6 +51,34 @@ ENDSEC; END-ISO-10303-21; `; +/** + * Body details for a 3 mm plate, 100 x 60 mm, in metres as Onshape reports them: one rounded corner (an arc), a + * 10 mm hole (a circle), and with `curved` a spline edge that needs Onshape's tessellation. The bottom face is the + * largest; the top is smaller because of a countersink. Shaped like BTExportModelBodiesResponse. + */ +export function mockPlateBody(partId: string, curved = false) { + const p = (x: number, y: number, z = 0) => ({ x, y, z }); + const line = (id: string, a: ReturnType, b: ReturnType) => ({ id, curve: { type: 'LINE', origin: a, direction: p(b.x - a.x, b.y - a.y) }, geometry: { startPoint: a, endPoint: b, midPoint: p((a.x + b.x) / 2, (a.y + b.y) / 2) }, vertices: [] }); + const r = Math.SQRT1_2 * 0.01; + const edges = [ + line('e1', p(0, 0), p(0.1, 0)), + line('e2', p(0.1, 0), p(0.1, 0.05)), + { id: 'e3', curve: { type: 'CIRCLE', origin: p(0.09, 0.05), normal: p(0, 0, 1), radius: 0.01 }, geometry: { startPoint: p(0.1, 0.05), endPoint: p(0.09, 0.06), midPoint: p(0.09 + r, 0.05 + r) }, vertices: [] }, + curved ? { id: 'e4', curve: { type: 'BCURVE' }, geometry: { startPoint: p(0.09, 0.06), endPoint: p(0, 0.06) }, vertices: [] } : line('e4', p(0.09, 0.06), p(0, 0.06)), + line('e5', p(0, 0.06), p(0, 0)), + { id: 'e6', curve: { type: 'CIRCLE', origin: p(0.03, 0.03), normal: p(0, 0, 1), radius: 0.005 }, geometry: { startPoint: p(0.035, 0.03), endPoint: p(0.035, 0.03) }, vertices: [] }, + line('t1', p(0, 0, 0.003), p(0.1, 0, 0.003)), + ]; + const loop = (outer: boolean, ids: string[]) => ({ isOuter: outer, isInner: !outer, coedges: ids.map(edgeId => ({ edgeId, orientation: true })) }); + return { bodies: [{ id: partId, type: 'SOLID', edges, vertices: [{ id: 'v1', point: p(0, 0, 0) }, { id: 'v2', point: p(0, 0, 0.003) }], faces: [ + { id: 'bottom', area: 0.0059, surface: { type: 'PLANE', origin: p(0, 0, 0), normal: p(0, 0, -1), isOrientedWithFace: true }, loops: [loop(true, ['e1', 'e2', 'e3', 'e4', 'e5']), loop(false, ['e6'])] }, + { id: 'top', area: 0.0058, surface: { type: 'PLANE', origin: p(0, 0, 0.003), normal: p(0, 0, 1), isOrientedWithFace: true }, loops: [loop(true, ['t1'])] }, + { id: 'hole', area: 0.0001, surface: { type: 'CYLINDER', origin: p(0.03, 0.03), axis: p(0, 0, 1), radius: 0.005 }, loops: [] }, + ] }] }; +} +/** Onshape's tessellation of the curved plate's spline edge: a gentle bow from (90, 60) to (0, 60) mm. */ +export const mockPlateEdges = (ids: string[]) => ({ bodies: [{ edges: ids.filter(id => id === 'e4').map(id => ({ id, vertices: [0, 0.25, 0.5, 0.75, 1].map(t => ({ x: 0.09 * (1 - t), y: 0.06 + 0.005 * Math.sin(Math.PI * t), z: 0 })) })) }] }); + export function createMockOnshape() { let bomFailure: number | null = null; const translationPolls = new Map(); @@ -122,6 +150,18 @@ export function createMockOnshape() { if (external[1] === 'bad-file') return new Response('Error', { headers: { 'content-type': 'text/html' } }); return new Response(mockStep(stepNames[external[1]] ?? external[1]), { headers: { 'content-type': 'application/octet-stream' } }); } + // Body details and tessellated edges for a 2D-cut part's DXF: every part is the mock plate. 'curved-plate' has a + // spline edge, 'no-face' has no planar face, and 'body-403' is refused. + const bodyDetails = url.pathname.match(/^\/api\/v\d+\/parts\/d\/[^/]+\/[wv]\/[^/]+\/e\/[^/]+\/partid\/([^/]+)\/(bodydetails|tessellatededges)$/); + if (bodyDetails) { + apiRequests++; + const partId = decodeURIComponent(bodyDetails[1]); + if (partId === 'body-403') return json(403, { error: 'forbidden' }); + if (bodyDetails[2] === 'tessellatededges') return json(200, mockPlateEdges(url.searchParams.getAll('edgeId').length ? url.searchParams.getAll('edgeId') : ['e4'])); + const body = mockPlateBody(partId, partId === 'curved-plate'); + if (partId === 'no-face') body.bodies[0].faces = body.bodies[0].faces.filter(face => face.surface.type !== 'PLANE'); + return json(200, body); + } // A shaded view: a stand-in PNG naming what was drawn, so tests can tell a part from the whole assembly. const shaded = url.pathname.match(/^\/api\/v\d+\/(assemblies|parts|partstudios)\/d\/(simple|robot|empty)\/([wvm])\/([^/]+)\/e\/[^/]+(?:\/partid\/[^/]+)?\/shadedviews$/); const view = url.searchParams.get('viewMatrix'); diff --git a/apps/worker/src/onshape-client.test.ts b/apps/worker/src/onshape-client.test.ts index e420157..5d94146 100644 --- a/apps/worker/src/onshape-client.test.ts +++ b/apps/worker/src/onshape-client.test.ts @@ -20,6 +20,8 @@ describe('Onshape request ledger', () => { expect(requestKind('/api/v9/parts/d/d1/m/m1/e/e1/partid/JHD/stl?mode=binary')).toBe('part-stl'); expect(requestKind('/api/v9/partstudios/d/d1/m/m1/e/e1/translations', 'POST')).toBe('translation-start'); expect(requestKind('/api/v9/translations/abc')).toBe('translation-status'); + expect(requestKind('/api/v9/parts/d/d1/w/w1/e/e1/partid/JHD/bodydetails?includeGeometricData=true')).toBe('body-details'); + expect(requestKind('/api/v9/parts/d/d1/v/v1/e/e1/partid/JHD/tessellatededges?edgeId=JHE')).toBe('tessellated-edges'); expect(requestKind('/api/v9/documents/d/d1/externaldata/x1')).toBe('external-data'); expect(requestKind('https://cad.onshape.com/api/v9/unknown')).toBe('other'); }); diff --git a/apps/worker/src/onshape-client.ts b/apps/worker/src/onshape-client.ts index addbe59..efb4694 100644 --- a/apps/worker/src/onshape-client.ts +++ b/apps/worker/src/onshape-client.ts @@ -3,7 +3,7 @@ import type { Kv } from './index'; /** Endpoint families counted against the owner's annual Onshape API allowance. */ export type OnshapeRequestKind = 'session-info' | 'microversion' | 'assembly-definition' | 'assembly-bom' | 'metadata' | 'element-info' | 'mass-properties' - | 'part-stl' | 'translation-start' | 'translation-status' | 'external-data' | 'shaded-view' | 'other'; + | 'part-stl' | 'translation-start' | 'translation-status' | 'external-data' | 'shaded-view' | 'body-details' | 'tessellated-edges' | 'other'; export type RequestPurpose = 'oauth-profile' | 'scan' | 'export' | 'preview'; /** Outcome of one outbound request; `error` means the fetch threw before a status arrived. */ export type LedgerEntry = { kind: OnshapeRequestKind; status: number | 'error'; retry: boolean }; @@ -23,6 +23,8 @@ export function requestKind(path: string, method = 'GET'): OnshapeRequestKind { if (/\/translations\/[^/]+$/.test(pathname)) return 'translation-status'; if (pathname.includes('/externaldata/')) return 'external-data'; if (pathname.endsWith('/shadedviews')) return 'shaded-view'; + if (pathname.endsWith('/bodydetails')) return 'body-details'; + if (pathname.endsWith('/tessellatededges')) return 'tessellated-edges'; return 'other'; } diff --git a/apps/worker/src/step-export.test.ts b/apps/worker/src/step-export.test.ts index d4d9d13..8e34024 100644 --- a/apps/worker/src/step-export.test.ts +++ b/apps/worker/src/step-export.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { aggregateAssembly, checkStep, classifyScan, type AssemblyInstance } from '@control-kit/shared'; -import { mockStep } from './mock-onshape'; -import { advanceExport, budgeted, BudgetExhaustedError, createExportJob, type RequestBudget } from './step-export'; +import { aggregateAssembly, checkStep, classifyScan, drawFlatPart, withDrawnDxf, type AssemblyInstance } from '@control-kit/shared'; +import { mockPlateBody, mockPlateEdges, mockStep } from './mock-onshape'; +import { advanceExport, budgeted, BudgetExhaustedError, createExportJob, dxfKey, jobSummary, type RequestBudget } from './step-export'; import type { Kv } from './index'; const kv = (): Kv => { const values = new Map(); return { get: async key => values.get(key) ?? null, put: async (key, value) => { values.set(key, value); }, delete: async key => { values.delete(key); } }; }; /** A scan with `count` printed parts in the assembly's own document, so every part has an export source. */ -function job(count: number) { - const instances: AssemblyInstance[] = Array.from({ length: count }, (_, n) => ({ id: `i${n}`, name: `Bracket ${n} [3DP]`, type: 'Part', documentId: 'doc', elementId: 'studio', partId: `p${n}` })); +function job(count: number, name = (n: number) => `Bracket ${n} [3DP]`) { + const instances: AssemblyInstance[] = Array.from({ length: count }, (_, n) => ({ id: `i${n}`, name: name(n), type: 'Part', documentId: 'doc', elementId: 'studio', partId: `p${n}` })); const result = aggregateAssembly({ rootAssembly: { instances } }, { documentId: 'doc', workspaceOrVersion: 'w', workspaceOrVersionId: 'work', elementId: 'asm' }, 'Robot'); classifyScan(result, { teamNumber: null }, {}); result.scanId = '00000000-0000-4000-8000-000000000000'; @@ -97,6 +97,86 @@ describe('STEP export job scheduling', () => { }); }); +/** Onshape stand-in for 2D-cut parts: STEP finishes on its first poll; body details describe the mock plate. */ +function cutting(options: { curved?: boolean; refuse?: boolean } = {}) { + const calls: string[] = []; + const fetcher = async (path: string, init?: RequestInit) => { + calls.push(`${init?.method ?? 'GET'} ${path}`); + if (init?.method === 'POST') return Response.json({ id: `tr-${(JSON.parse(String(init.body)) as { partIds: string }).partIds}`, requestState: 'ACTIVE' }); + if (path.includes('/bodydetails')) return options.refuse ? new Response(null, { status: 404 }) : Response.json(mockPlateBody(path.match(/partid\/(\w+)/)![1], options.curved)); + if (path.includes('/tessellatededges')) return Response.json(mockPlateEdges(['e4'])); + const translation = path.match(/translations\/tr-(\w+)$/); + if (translation) return Response.json({ requestState: 'DONE', resultExternalDataIds: [`x-${translation[1]}`] }); + return new Response(mockStep(`Side Plate ${path.match(/x-p(\d+)$/)![1]}`)); + }; + return { fetcher, calls }; +} +const plates = (count: number) => job(count, n => `Side Plate ${n}`); + +describe('DXF for 2D-cut parts', () => { + it('stores the body details of each flat part beside its STEP from one request, for the browser to draw', async () => { + const work = plates(1); + expect(work.parts[0]).toMatchObject({ disposition: 'cut-2d', fileName: 'Side_Plate_0__p0.step', dxf: { fileName: 'Side_Plate_0__p0.dxf', state: 'pending' } }); + const store = kv(); + const { fetcher, calls } = cutting(); + for (let poll = 0; poll < 2; poll++) { const budget = { remaining: 12 }; await advanceExport(work, 'u', store, budgeted(fetcher, budget), 'v9', budget); } + expect(work.parts[0]).toMatchObject({ state: 'ready', dxf: { state: 'ready', error: null, edges: false } }); + expect(calls.filter(call => call.includes('/bodydetails'))).toEqual(['GET /api/v9/parts/d/doc/w/work/e/studio/partid/p0/bodydetails?includeGeometricData=true']); + expect(calls.some(call => call.includes('tessellatededges'))).toBe(false); + expect(await store.get(dxfKey('u', work.jobId, 0, 'edges'))).toBeNull(); + expect(jobSummary(work)).toMatchObject({ done: true, parts: [{ dxf: { state: 'ready' } }] }); + // The browser's step: draw the DXF and fill in its sizes. + const drawn = drawFlatPart(JSON.parse((await store.get(dxfKey('u', work.jobId, 0, 'body')))!), null, 'p0'); + expect(drawn.ok && drawn.text.startsWith('0\nSECTION')).toBe(true); + expect(withDrawnDxf(work.parts[0], drawn).dxf).toMatchObject({ state: 'ready', widthMm: 100, heightMm: 60, thicknessMm: 3 }); + expect(withDrawnDxf(work.parts[0], null).dxf).toMatchObject({ state: 'failed', error: 'DXF was not downloaded' }); + }); + it('reads tessellated edges only when the part has a curve that is not an arc, at the part configuration', async () => { + const work = plates(1); + work.parts[0].configuration = 'Length=200 mm'; + const store = kv(); + const { fetcher, calls } = cutting({ curved: true }); + const budget = { remaining: 12 }; + await advanceExport(work, 'u', store, budgeted(fetcher, budget), 'v9', budget); + expect(calls.filter(call => call.startsWith('GET /api/v9/parts/'))).toEqual([ + 'GET /api/v9/parts/d/doc/w/work/e/studio/partid/p0/bodydetails?configuration=Length%3D200%20mm&includeGeometricData=true', + 'GET /api/v9/parts/d/doc/w/work/e/studio/partid/p0/tessellatededges?configuration=Length%3D200%20mm']); + expect(work.parts[0].dxf).toMatchObject({ state: 'ready', edges: true }); + const read = async (kind: 'body' | 'edges') => JSON.parse((await store.get(dxfKey('u', work.jobId, 0, kind)))!); + expect(drawFlatPart(await read('body'), await read('edges'), 'p0')).toMatchObject({ ok: true, heightMm: 65 }); + expect(drawFlatPart(await read('body'), null, 'p0')).toEqual({ ok: false, error: 'Onshape gave no shape for a curved edge of the flat face' }); + }); + it('fails only the DXF when Onshape refuses it, and the STEP still finishes', async () => { + const work = plates(1); + const { fetcher } = cutting({ refuse: true }); + for (let poll = 0; poll < 2; poll++) { const budget = { remaining: 12 }; await advanceExport(work, 'u', kv(), budgeted(fetcher, budget), 'v9', budget); } + expect(work.parts[0]).toMatchObject({ state: 'ready', error: null, dxf: { state: 'failed', error: 'DXF: No access to this part in Onshape' } }); + expect(jobSummary(work).done).toBe(true); + }); + it('waits for a poll with room for both DXF requests, and keeps the job open until the DXF is made', async () => { + const work = plates(1); + const { fetcher, calls } = cutting(); + let budget = { remaining: 2 }; + await advanceExport(work, 'u', kv(), budgeted(fetcher, budget), 'v9', budget); + // The STEP start took one request; one left is not enough to begin a DXF. + expect(calls).toHaveLength(1); + expect(work.parts[0]).toMatchObject({ state: 'translating', dxf: { state: 'pending' } }); + budget = { remaining: 1 }; + await advanceExport(work, 'u', kv(), budgeted(fetcher, budget), 'v9', budget); + expect(work.parts[0].dxf!.state).toBe('pending'); + expect(jobSummary(work).done).toBe(false); + }); + it('gives a part with no export source a failed DXF too, and other processes none', () => { + expect(job(1).parts[0].dxf).toBeUndefined(); + const result = aggregateAssembly({ rootAssembly: { instances: [{ id: 'i', name: 'Side Plate', type: 'Part', documentId: 'doc', elementId: 'studio', partId: 'p0' }] } }, + { documentId: 'doc', workspaceOrVersion: 'w', workspaceOrVersionId: 'work', elementId: 'asm' }, 'Robot'); + classifyScan(result, { teamNumber: null }, {}); + result.lines[0].exportSource = null; + result.scanId = 's'; + expect(createExportJob(result, 'j').parts[0]).toMatchObject({ state: 'failed', dxf: { state: 'failed', error: 'No workspace or version to export from (linked part without a version)' } }); + }); +}); + describe('STEP product name check', () => { it('compares names in any script and never matches an empty name', () => { const file = (name: string) => `ISO-10303-21;\nDATA;\n#1=PRODUCT('${name}','','',(#2));\nENDSEC;\nEND-ISO-10303-21;\n`; diff --git a/apps/worker/src/step-export.ts b/apps/worker/src/step-export.ts index e135c03..32d51fe 100644 --- a/apps/worker/src/step-export.ts +++ b/apps/worker/src/step-export.ts @@ -1,4 +1,4 @@ -import { checkStep, stepFileName, wantsStep, type ExportPart, type ScanResult } from '@control-kit/shared'; +import { checkStep, dxfFileName, needsTessellatedEdges, stepFileName, wantsDxf, wantsStep, type DxfPart, type ExportPart, type ScanResult } from '@control-kit/shared'; import type { Kv } from './index'; import { SessionExpiredError } from './scan'; @@ -7,6 +7,9 @@ import { SessionExpiredError } from './scan'; * outbound calls, so each call to `advanceExport` does a slice of the work: start pending translations, poll running * ones, and download and check finished files. The browser polls until every part is ready or failed. * Per part: 1 translation start + its status polls + 1 download, all counted in the request ledger. + * A 2D-cut part also gets a DXF, drawn in the browser from its body details (see packages/shared/src/dxf.ts): + * 1 request, plus 1 for tessellated edges only when the part has a spline or ellipse. It runs beside the STEP and + * never fails it. */ export type JobPart = ExportPart & { documentId: string; elementId: string; exportSource: { type: 'w' | 'v'; id: string } | null; translationId: string | null; resultDocumentId: string | null; startedAt: number | null; /** Set once Onshape reports the translation DONE: the file still to download, no longer subject to the translation deadline. */ @@ -19,12 +22,14 @@ export type ExportJob = { jobId: string; documentId: string; scanId: string; ass type FetchOnshape = (path: string, init?: RequestInit) => Promise; /** Public view of a job: no Onshape IDs beyond what the user's own scan already showed. */ export const jobSummary = (job: ExportJob) => ({ jobId: job.jobId, assemblyName: job.assemblyName, documentName: job.documentName, revision: job.revision, scannedAt: job.scannedAt, createdAt: job.createdAt, - done: job.parts.every(part => part.state === 'ready' || part.state === 'failed'), + done: job.parts.every(part => (part.state === 'ready' || part.state === 'failed') && part.dxf?.state !== 'pending'), rateLimited: Boolean(job.rateLimitedAt && Date.now() - job.rateLimitedAt < 60000), - parts: job.parts.map(({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes }) => ({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes })) }); + parts: job.parts.map(({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes, dxf }) => ({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes, ...(dxf ? { dxf } : {}) })) }); export const jobKey = (userId: string, jobId: string) => `export:${userId}:${jobId}`; export const fileKey = (userId: string, jobId: string, index: number) => `export-file:${userId}:${jobId}:${index}`; +/** Onshape's geometry for a 2D-cut part's DXF: its body details, and its tessellated edges when it has curves. */ +export const dxfKey = (userId: string, jobId: string, index: number, kind: 'body' | 'edges') => `export-file:${userId}:${jobId}:${index}:${kind}`; /** A translation that has not finished after this long is reported as failed instead of polled forever. */ const translationTimeoutMs = 5 * 60000; const maxFileBytes = 20 * 1024 * 1024; @@ -33,8 +38,10 @@ export function createExportJob(result: ScanResult, jobId: string, now = new Dat const used = new Set(); const parts: JobPart[] = result.lines.filter(wantsStep).map(line => { const missing = !line.partId ? 'Part ID unavailable' : !line.exportSource ? 'No workspace or version to export from (linked part without a version)' : null; - return { lineKey: line.bomLineKey, name: line.name, qty: line.qty, disposition: line.disposition, partId: line.partId, configuration: line.configuration, sourceUrl: line.partStudioUrl, fileName: stepFileName(line, used), - state: missing ? 'failed' : 'pending', error: missing, units: null, nameMatches: null, bytes: null, + const fileName = stepFileName(line, used); + const dxf: DxfPart | undefined = wantsDxf(line) ? { fileName: dxfFileName(fileName), state: missing ? 'failed' : 'pending', error: missing, bytes: null, widthMm: null, heightMm: null, thicknessMm: null } : undefined; + return { lineKey: line.bomLineKey, name: line.name, qty: line.qty, disposition: line.disposition, partId: line.partId, configuration: line.configuration, sourceUrl: line.partStudioUrl, fileName, + state: missing ? 'failed' : 'pending', error: missing, units: null, nameMatches: null, bytes: null, ...(dxf ? { dxf } : {}), documentId: line.documentId, elementId: line.elementId, exportSource: line.exportSource ?? null, translationId: null, resultDocumentId: null, startedAt: null }; }); return { jobId, documentId: result.documentId, scanId: result.scanId!, assemblyName: result.assemblyName, documentName: result.documentName, revision: result.diagnostics?.revision ?? null, @@ -68,12 +75,21 @@ export async function advanceExport(job: ExportJob, userId: string, kv: Kv, fetc const encode = encodeURIComponent; const pending = job.parts.flatMap((part, index) => part.state === 'pending' ? [index] : []); const running = job.parts.flatMap((part, index) => part.state === 'translating' ? [index] : []); + const cutting = job.parts.flatMap((part, index) => part.dxf?.state === 'pending' ? [index] : []); const start = running.length ? (job.cursor ?? 0) % running.length : 0; let polled = 0; - for (const index of [...pending, ...running.slice(start), ...running.slice(0, start)]) { + // STEP translations start first (Onshape runs them in parallel), then flat parts' geometry is read for their DXFs, then running translations are polled. + const tasks = [...pending.map(index => ({ index, dxf: false })), ...cutting.map(index => ({ index, dxf: true })), ...[...running.slice(start), ...running.slice(0, start)].map(index => ({ index, dxf: false }))]; + for (const { index, dxf } of tasks) { if (budget.remaining <= 0) break; const part = job.parts[index]; try { + if (dxf) { + // A DXF may need two requests; starting one with a single request left would only repeat the first next poll. + if (budget.remaining < 2) continue; + await readFlatPart(job, part, index, userId, kv, fetchOnshape, apiVersion); + continue; + } if (part.state === 'pending') { const { type, id } = part.exportSource!; // Onshape PartStudio/createPartStudioTranslation (OAuth2Read): STEP, one part, its configuration, not stored in the document. @@ -116,9 +132,42 @@ export async function advanceExport(job: ExportJob, userId: string, kv: Kv, fetc if (error instanceof BudgetExhaustedError) break; // Session expiry and cancellation belong to the whole request; anything else fails only this part. if (error instanceof SessionExpiredError || (error instanceof Error && error.name === 'AbortError')) throw error; - Object.assign(part, { state: 'failed', error: 'Could not reach Onshape for this part' }); + // A DXF failure is the DXF's alone: the part's STEP carries on. + if (dxf) part.dxf = { ...part.dxf!, state: 'failed', error: 'DXF: could not reach Onshape for this part' }; + else Object.assign(part, { state: 'failed', error: 'Could not reach Onshape for this part' }); } } job.cursor = running.length ? (start + polled) % running.length : 0; return job; } + +/** + * Reads what a 2D-cut part's DXF is drawn from, and stores it for the browser, which draws the file (see + * packages/shared/src/dxf.ts). Onshape Part/getBodyDetails (OAuth2Read) at the STEP's own workspace or version and + * configuration: 1 request. Part/getEdges (tessellated edges, OAuth2Read): 1 more, only when the body details name a + * spline or ellipse. Nothing is parsed here, so a large plate costs the Worker no CPU beyond a text search. + * Throws RateLimitedError or BudgetExhaustedError to leave the DXF pending for the next poll. + */ +async function readFlatPart(job: ExportJob, part: JobPart, index: number, userId: string, kv: Kv, fetchOnshape: FetchOnshape, apiVersion: string) { + const encode = encodeURIComponent; + const fail = (error: string) => { part.dxf = { ...part.dxf!, state: 'failed', error }; }; + const { type, id } = part.exportSource!; + const base = `/api/${apiVersion}/parts/d/${encode(part.documentId)}/${type}/${encode(id)}/e/${encode(part.elementId)}/partid/${encode(part.partId!)}`; + const configuration = part.configuration && part.configuration !== 'default' ? `configuration=${encode(part.configuration)}&` : ''; + const read = async (path: string, what: string) => { + const response = await fetchOnshape(path); + if (response.status === 429) throw new RateLimitedError(); + if (!response.ok) { fail(`DXF: ${what}${failure(response.status)}`); return null; } + const text = await response.text(); + if (text.length > maxFileBytes) { fail('DXF: this part is too detailed to draw here; export its face from Onshape directly'); return null; } + if (!/^\s*\{/.test(text)) { fail('DXF: Onshape returned something other than part geometry'); return null; } + return text; + }; + const details = await read(`${base}/bodydetails?${configuration}includeGeometricData=true`, ''); + if (details === null) return; + const edges = needsTessellatedEdges(details) ? await read(`${base}/tessellatededges${configuration ? `?${configuration.slice(0, -1)}` : ''}`, 'curved edges could not be read: ') : undefined; + if (edges === null) return; + await kv.put(dxfKey(userId, job.jobId, index, 'body'), details, { expirationTtl: 3600 }); + if (edges) await kv.put(dxfKey(userId, job.jobId, index, 'edges'), edges, { expirationTtl: 3600 }); + part.dxf = { ...part.dxf!, state: 'ready', error: null, edges: Boolean(edges) }; +} diff --git a/packages/excel/src/step-zip.test.ts b/packages/excel/src/step-zip.test.ts index e0453cf..b227bc0 100644 --- a/packages/excel/src/step-zip.test.ts +++ b/packages/excel/src/step-zip.test.ts @@ -42,8 +42,32 @@ describe('STEP ZIP', () => { expect(manifest).toContain('Files: 2 of 3 parts exported, 1 failed'); expect(manifest).toContain('Plate.step | Plate | 2 | 2D cut (laser/waterjet/router) | mm |'); expect(manifest).toContain('Motor (qty 2): Deliberate failure | https://cad.onshape.com/documents/d/w/w/e/Motor'); - expect(manifest).toContain('still needs a cut file (DXF)'); - expect(zip).toMatchObject({ filename: 'MR_HORS_STEP.zip', ready: 2, failed: 1 }); + expect(manifest).not.toContain('DXF'); + expect(zip).toMatchObject({ filename: 'MR_HORS_STEP.zip', ready: 2, failed: 1, dxf: 0 }); + }); + it("puts each 2D-cut part's DXF beside its STEP, lists it in the manifest, and reports a DXF failure without dropping the STEP", () => { + const dxf = (state: 'ready' | 'failed', error: string | null = null) => ({ fileName: '', state, error, bytes: 120, widthMm: 100, heightMm: 60.5, thicknessMm: 3.175 }); + const parts = [ + part('Plate', 'ready', { disposition: 'cut-2d', fileName: 'Plate__p1.step', dxf: { ...dxf('ready'), fileName: 'Plate__p1.dxf' } }), + part('Gusset', 'ready', { disposition: 'cut-2d', fileName: 'Gusset__p2.step', dxf: { ...dxf('failed', 'This part has no flat face to cut from'), fileName: 'Gusset__p2.dxf' } }), + part('Bracket', 'failed', { disposition: 'cut-2d', fileName: 'Bracket__p3.step', dxf: { ...dxf('ready'), fileName: 'Bracket__p3.dxf' } }), + part('Arm', 'ready', { disposition: 'cut-2d', fileName: 'Arm__p4.step', dxf: { ...dxf('ready'), fileName: 'Arm__p4.dxf' } }), + ]; + const cut = '0\nSECTION\n2\nENTITIES\n0\nLINE\n8\nCUT\n10\n0\n20\n0\n30\n0\n11\n10\n21\n0\n31\n0\n0\nENDSEC\n0\nEOF\n'; + const zip = buildStepZip({ assemblyName: 'Robot', scannedAt: '2026-10-24T00:00:00Z', parts, files: { 0: step('Plate'), 1: step('Gusset'), 3: step('Arm') }, dxfFiles: { 0: cut, 2: cut }, now: new Date('2026-10-24T01:00:00Z') }); + const files = unzipSync(zip.bytes); + expect(Object.keys(files).sort()).toEqual(['Robot_STEP/2D_cut/Arm__p4.step', 'Robot_STEP/2D_cut/Bracket__p3.dxf', 'Robot_STEP/2D_cut/Gusset__p2.step', 'Robot_STEP/2D_cut/Plate__p1.dxf', 'Robot_STEP/2D_cut/Plate__p1.step', 'Robot_STEP/MANIFEST.txt']); + expect(strFromU8(files['Robot_STEP/2D_cut/Plate__p1.dxf']).startsWith('0\nSECTION')).toBe(true); + const manifest = strFromU8(files['Robot_STEP/MANIFEST.txt']); + expect(manifest).toContain('DXF cut files: 2 of 4 2D-cut parts, 2 failed (listed below)'); + expect(manifest).toContain('Plate__p1.dxf | Plate | 2 | 100 x 60.5 | 3.18 |'); + expect(manifest).toContain('DXF not made (the STEP file is unaffected):'); + expect(manifest).toContain('Gusset (qty 2): This part has no flat face to cut from'); + expect(manifest).toContain('Arm (qty 2): DXF was not downloaded'); + expect(zip).toMatchObject({ ready: 3, failed: 1, dxf: 2 }); + // A DXF alone still makes a ZIP: the cut file is useful without its STEP. + const only = buildStepZip({ assemblyName: 'Robot', scannedAt: '', parts: [parts[2]], files: {}, dxfFiles: { 0: cut } }); + expect(Object.keys(unzipSync(only.bytes))).toContain('Robot_STEP/2D_cut/Bracket__p3.dxf'); }); it('refuses to build a ZIP when every part failed or no file arrived', () => { expect(() => buildStepZip({ assemblyName: 'Robot', scannedAt: '', parts: [part('Motor', 'failed')], files: {} })).toThrow(NoStepFilesError); diff --git a/packages/excel/src/step-zip.ts b/packages/excel/src/step-zip.ts index 5dae951..f812c96 100644 --- a/packages/excel/src/step-zip.ts +++ b/packages/excel/src/step-zip.ts @@ -3,23 +3,33 @@ import { exportManifest, type ExportPart } from '../../shared/src/index'; export type StepZipInput = { assemblyName: string; documentName?: string; revision?: { type: string; id: string } | null; scannedAt: string; parts: ExportPart[]; /** File text by part index, for parts whose state is `ready`. */ - files: Record; now?: Date }; + files: Record; + /** DXF text by part index, for 2D-cut parts whose `dxf.state` is `ready`. */ + dxfFiles?: Record; now?: Date }; /** Thrown when no part produced a file: a ZIP holding only a report would look like a successful export. */ export class NoStepFilesError extends Error {} /** - * Builds the download in the browser: successful STEP files grouped by process folder, plus MANIFEST.txt listing - * every file and every failure with its Onshape link. Failures are never only inside the ZIP; the app shows them too. + * Builds the download in the browser: successful STEP files grouped by process folder, each 2D-cut part's DXF beside + * its STEP, plus MANIFEST.txt listing every file and every failure with its Onshape link. Failures are never only + * inside the ZIP; the app shows them too. */ export function buildStepZip(input: StepZipInput) { const root = `${input.assemblyName.replace(/[^\w\- ]/g, '').trim().replace(/\s+/g, '_').slice(0, 80) || 'Assembly'}_STEP`; const folders: Record = { print: '3D_print', cnc: 'CNC', 'cut-2d': '2D_cut', fabricate: 'Fabricate', buy: 'Other', hardware: 'Other', ignore: 'Other', review: 'Other' }; const files: Record = {}; - const parts = input.parts.map((part, index) => part.state === 'ready' && input.files[index] === undefined ? { ...part, state: 'failed' as const, error: 'File was not downloaded' } : part); - parts.forEach((part, index) => { if (part.state === 'ready') files[`${root}/${folders[part.disposition]}/${part.fileName}`] = strToU8(input.files[index]); }); + const dxfFiles = input.dxfFiles ?? {}; + const parts = input.parts.map((part, index) => { + const step = part.state === 'ready' && input.files[index] === undefined ? { ...part, state: 'failed' as const, error: 'File was not downloaded' } : part; + return step.dxf?.state === 'ready' && dxfFiles[index] === undefined ? { ...step, dxf: { ...step.dxf, state: 'failed' as const, error: 'DXF was not downloaded' } } : step; + }); + parts.forEach((part, index) => { + if (part.state === 'ready') files[`${root}/${folders[part.disposition]}/${part.fileName}`] = strToU8(input.files[index]); + if (part.dxf?.state === 'ready') files[`${root}/${folders[part.disposition]}/${part.dxf.fileName}`] = strToU8(dxfFiles[index]); + }); if (!Object.keys(files).length) throw new NoStepFilesError(`No STEP file could be exported (${parts.length} part${parts.length === 1 ? '' : 's'} failed).`); const exportedAt = (input.now ?? new Date()).toISOString(); files[`${root}/MANIFEST.txt`] = strToU8(exportManifest({ assemblyName: input.assemblyName, documentName: input.documentName, revision: input.revision, scannedAt: input.scannedAt, exportedAt, parts })); - return { bytes: zipSync(files, { level: 6, mtime: input.now ?? new Date() }), filename: `${root}.zip`, ready: parts.filter(part => part.state === 'ready').length, failed: parts.filter(part => part.state === 'failed').length }; + return { bytes: zipSync(files, { level: 6, mtime: input.now ?? new Date() }), filename: `${root}.zip`, ready: parts.filter(part => part.state === 'ready').length, failed: parts.filter(part => part.state === 'failed').length, dxf: parts.filter(part => part.dxf?.state === 'ready').length }; } diff --git a/packages/excel/src/workbook.test.ts b/packages/excel/src/workbook.test.ts index f983ab3..26ef52d 100644 --- a/packages/excel/src/workbook.test.ts +++ b/packages/excel/src/workbook.test.ts @@ -122,6 +122,15 @@ describe('Excel export', () => { expect(text(zip, 1)).toContain('Out of date'); }); + it("names a 2D-cut part's DXF beside its STEP file on the Make sheet", () => { + const plated = robot(); + const bracket = plated.lines.find(line => line.partId === 'bracket')!; + const cut = classifyScan(plated, { teamNumber: null }, { [bracket.bomLineKey]: 'cut-2d' }); + const make = text(unzipSync(buildExcelWorkbook(cut, new Date('2026-10-24T12:00:00Z')).bytes), 3); + expect(row(make, 'Mystery Bracket')).toMatch(/Mystery_Bracket__bracket\.step.*Mystery_Bracket__bracket\.dxf/); + expect(row(make, 'Intake Guide')).not.toContain('.dxf'); + }); + it('shows "None priced" rather than $0.00 when no line has a total', () => { const unpriced = robot(); for (const line of unpriced.lines) Object.assign(line, { vendor: null, matchStatus: 'unmatched', packsToOrder: null, lineTotalUsd: null }); diff --git a/packages/excel/src/workbook.ts b/packages/excel/src/workbook.ts index 38994d4..11d0fdc 100644 --- a/packages/excel/src/workbook.ts +++ b/packages/excel/src/workbook.ts @@ -1,5 +1,5 @@ import { strToU8, zipSync } from 'fflate'; -import { isBundle, metadataIssueText, stepFileName, wantsStep, type BomLine, type CatalogHealth, type Disposition, type ScanResult, type VendorHealth, type VendorRecord } from '../../shared/src/index'; +import { dxfFileName, isBundle, metadataIssueText, stepFileName, wantsDxf, wantsStep, type BomLine, type CatalogHealth, type Disposition, type ScanResult, type VendorHealth, type VendorRecord } from '../../shared/src/index'; type Cell = { col: number; type: 's' | 'n' | 'f'; value: string | number; style: number; cached?: number }; type Sheet = { name: string; rows: Map; widths: number[]; fixed: Record; merges: string[]; links: { cell: string; url: string }[]; filter?: string; frozen?: number; landscape?: boolean; drawing?: boolean; tall?: number[] }; @@ -182,12 +182,12 @@ function buySheet(result: ScanResult, now: Date) { return { target, first, last, total, priced: pricedLines.length, lines: purchases.length }; } -/** Parts the team makes, by process, with the STEP file name each one gets in the export ZIP. */ +/** Parts the team makes, by process, with the STEP file name each one gets in the export ZIP, and a 2D-cut part's DXF. */ function makeSheet(result: ScanResult, now: Date) { - const names = ['#', 'Part', 'Qty Needed', 'Process', 'Material', 'STEP file', 'Onshape Part Studio', 'Why']; - const target = sheet('Make', names.length, { 2: 44, 6: 44, 8: 52 }); + const names = ['#', 'Part', 'Qty Needed', 'Process', 'Material', 'STEP file', 'DXF file', 'Onshape Part Studio', 'Why']; + const target = sheet('Make', names.length, { 2: 44, 6: 44, 7: 44, 9: 52 }); heading(target, 'MAKE', result, now, 1); - put(target, 4, 1, 'STEP files come from Download STEP files in Control Kit; each file in the ZIP has the name shown here, in a folder for its process.', S.note); + put(target, 4, 1, 'STEP files come from Download STEP files in Control Kit; each file in the ZIP has the name shown here, in a folder for its process. A 2D-cut part also gets a DXF of its flat face, in millimetres.', S.note); target.merges.push(`A4:${cellRef(names.length, 4)}`); target.tall = [4]; headers(target, 6, names); const files = stepNames(result); @@ -201,9 +201,11 @@ function makeSheet(result: ScanResult, now: Date) { put(target, row, 3, line.qty, body); put(target, row, 4, shortLabel[line.disposition], body); put(target, row, 5, line.material ?? '', body); - put(target, row, 6, files.get(line.bomLineKey) ?? '', body); - put(target, row, 7, 'Open Part Studio', band(index, S.link, S.linkAlt), 's', line.partStudioUrl); - put(target, row, 8, line.dispositionEvidence.join('; '), band(index, S.wrap, S.wrapAlt)); + const file = files.get(line.bomLineKey) ?? ''; + put(target, row, 6, file, body); + put(target, row, 7, file && wantsDxf(line) ? dxfFileName(file) : '', body); + put(target, row, 8, 'Open Part Studio', band(index, S.link, S.linkAlt), 's', line.partStudioUrl); + put(target, row, 9, line.dispositionEvidence.join('; '), band(index, S.wrap, S.wrapAlt)); }); if (!lines.length) put(target, 7, 1, 'No parts are marked to make yet.', S.muted); target.filter = `A6:${cellRef(names.length, Math.max(7, 6 + lines.length))}`; @@ -333,7 +335,7 @@ function overviewSheet(result: ScanResult, now: Date, buy: ReturnType }> }; +export type BodyDetailsBody = { id?: string; type?: string; faces?: BodyDetailsFace[]; edges?: BodyDetailsEdge[]; vertices?: Array<{ id?: string; point?: RawVec }> }; +/** The part of Onshape's BTExportModelBodiesResponse that a cut file needs. */ +export type BodyDetails = { bodies?: BodyDetailsBody[] }; +/** The part of Onshape's BTExportTessellatedEdgesResponse that a cut file needs. */ +export type TessellatedEdges = { bodies?: Array<{ edges?: Array<{ id?: string; vertices?: RawVec[] }> }> }; + +export type FlatFace = { faceId: string; areaMm2: number; normal: Vec3; origin: Vec3; body: BodyDetailsBody; loops: Array<{ outer: boolean; edgeIds: string[] }>; + /** Edges that are neither lines nor circles; they are drawn from Onshape's tessellation. */ + needsTessellation: string[]; + /** Distance across the part along the face normal, in mm; null when the body gives no points to measure. */ + thicknessMm: number | null }; +export type DxfResult = { ok: true; text: string; widthMm: number; heightMm: number; thicknessMm: number | null; entities: number } | { ok: false; error: string }; + +const vec = (value: RawVec): Vec3 | null => { + const [x, y, z] = Array.isArray(value) ? value : value ? [value.x, value.y, value.z] : []; + return [x, y, z].every(n => typeof n === 'number' && Number.isFinite(n)) ? { x: x!, y: y!, z: z! } : null; +}; +const sub = (a: Vec3, b: Vec3): Vec3 => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }); +const dot = (a: Vec3, b: Vec3) => a.x * b.x + a.y * b.y + a.z * b.z; +const cross = (a: Vec3, b: Vec3): Vec3 => ({ x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x }); +const scale = (a: Vec3, k: number): Vec3 => ({ x: a.x * k, y: a.y * k, z: a.z * k }); +/** Smallest value and range of a list, without spreading it into arguments (a long tessellation could overflow). */ +const least = (values: number[]) => values.reduce((low, value) => Math.min(low, value), Infinity); +const span = (values: number[]) => values.reduce((high, value) => Math.max(high, value), -Infinity) - least(values); +const unit = (a: Vec3): Vec3 | null => { const length = Math.hypot(a.x, a.y, a.z); return length > 1e-12 ? scale(a, 1 / length) : null; }; + +/** Every point the body gives (vertices and edge ends), for measuring thickness. */ +function bodyPoints(body: BodyDetailsBody) { + return [...(body.vertices ?? []).map(vertex => vec(vertex.point)), ...(body.edges ?? []).flatMap(edge => [vec(edge.geometry?.startPoint), vec(edge.geometry?.endPoint), vec(edge.geometry?.midPoint)])] + .filter((point): point is Vec3 => point !== null); +} + +/** + * The face a flat part is cut from: the largest planar face of the part's solid body. For a plate, the largest face + * is a side of the plate; a blind pocket makes its own side smaller, so the through profile wins. + */ +export function pickFlatFace(details: BodyDetails, partId: string | null): { ok: true; face: FlatFace } | { ok: false; error: string } { + const bodies = (details.bodies ?? []).filter(body => !body.type || body.type === 'SOLID'); + const body = bodies.find(item => partId && item.id === partId) ?? (bodies.length === 1 ? bodies[0] : undefined); + if (!body) return { ok: false, error: bodies.length ? 'Onshape returned several bodies for this part' : 'Onshape returned no solid body for this part' }; + let best: { face: BodyDetailsFace; normal: Vec3; origin: Vec3; area: number } | null = null; + for (const face of body.faces ?? []) { + if (face.surface?.type !== 'PLANE') continue; + const normal = unit(vec(face.surface.normal) ?? { x: 0, y: 0, z: 0 }); + const origin = vec(face.surface.origin); + const area = typeof face.area === 'number' && Number.isFinite(face.area) ? face.area : 0; + if (!normal || !origin || area <= 0 || !face.loops?.some(loop => loop.isOuter && loop.coedges?.length)) continue; + if (!best || area > best.area * (1 + 1e-9)) best = { face, normal: face.surface.isOrientedWithFace === false ? scale(normal, -1) : normal, origin, area }; + } + if (!best) return { ok: false, error: 'This part has no flat face to cut from' }; + const edges = new Map((body.edges ?? []).map(edge => [edge.id, edge])); + const loops = (best.face.loops ?? []).map(loop => ({ outer: Boolean(loop.isOuter), edgeIds: (loop.coedges ?? []).flatMap(coedge => coedge.edgeId ? [coedge.edgeId] : []) })).filter(loop => loop.edgeIds.length); + const missing = loops.flatMap(loop => loop.edgeIds).find(id => !edges.has(id)); + if (missing) return { ok: false, error: 'Onshape body details name an edge they do not describe' }; + const needsTessellation = [...new Set(loops.flatMap(loop => loop.edgeIds).filter(id => !['LINE', 'CIRCLE'].includes(edges.get(id)!.curve?.type ?? '')))]; + const along = bodyPoints(body).map(point => dot(sub(point, best!.origin), best!.normal)); + const thicknessMm = along.length ? Math.round(span(along) * 1000 * 1000) / 1000 : null; + return { ok: true, face: { faceId: best.face.id ?? '', areaMm2: best.area * 1e6, normal: best.normal, origin: best.origin, body, loops, needsTessellation, thicknessMm } }; +} + +/** Tessellated points by edge ID, from Onshape's tessellated edges response. */ +export function tessellatedPoints(response: TessellatedEdges) { + const points = new Map(); + for (const body of response.bodies ?? []) for (const edge of body.edges ?? []) { + const list = (edge.vertices ?? []).map(vec).filter((point): point is Vec3 => point !== null); + if (edge.id && list.length >= 2) points.set(edge.id, list); + } + return points; +} + +type Entity = { type: 'LINE'; a: [number, number]; b: [number, number] } | { type: 'CIRCLE'; c: [number, number]; r: number } | { type: 'ARC'; c: [number, number]; r: number; start: number; end: number }; +const degrees = (radians: number) => ((radians * 180 / Math.PI) % 360 + 360) % 360; +/** Counter-clockwise sweep from `from` to `to`, in degrees, in [0, 360). */ +const sweep = (from: number, to: number) => ((to - from) % 360 + 360) % 360; +const number = (value: number) => { const fixed = value.toFixed(6).replace(/\.?0+$/, ''); return fixed === '-0' ? '0' : fixed; }; + +/** + * Draws the face's loops in the face's own plane: x along its longest straight outer edge (so a rectangular plate + * lies square), y completing a right-handed frame seen from outside the part, origin at the lower-left corner. + */ +export function flatFaceDxf(face: FlatFace, tessellated: Map = new Map()): DxfResult { + const edges = new Map((face.body.edges ?? []).map(edge => [edge.id!, edge])); + const vertices = new Map((face.body.vertices ?? []).map(vertex => [vertex.id, vec(vertex.point)])); + const ends = (edge: BodyDetailsEdge) => { + const start = vec(edge.geometry?.startPoint) ?? vertices.get(edge.vertices?.[0]) ?? null; + const end = vec(edge.geometry?.endPoint) ?? vertices.get(edge.vertices?.[edge.vertices.length - 1]) ?? null; + return { start, end }; + }; + // X axis: the longest straight edge of the outer loop, else a world axis laid into the plane. + let xAxis: Vec3 | null = null; + let longest = 0; + for (const id of face.loops.find(loop => loop.outer)?.edgeIds ?? []) { + const edge = edges.get(id)!; + const { start, end } = ends(edge); + if (edge.curve?.type !== 'LINE' || !start || !end) continue; + const along = sub(end, start); + const flat = sub(along, scale(face.normal, dot(along, face.normal))); + const length = Math.hypot(flat.x, flat.y, flat.z); + if (length > longest * (1 + 1e-9)) { longest = length; xAxis = unit(flat); } + } + if (!xAxis) { + const world = Math.abs(face.normal.x) < 0.9 ? { x: 1, y: 0, z: 0 } : { x: 0, y: 1, z: 0 }; + xAxis = unit(sub(world, scale(face.normal, dot(world, face.normal))))!; + } + const yAxis = cross(face.normal, xAxis); + const flat = (point: Vec3): [number, number] => { const offset = sub(point, face.origin); return [dot(offset, xAxis!) * 1000, dot(offset, yAxis) * 1000]; }; + + const entities: Entity[] = []; + for (const id of face.loops.flatMap(loop => loop.edgeIds)) { + const edge = edges.get(id)!; + const { start, end } = ends(edge); + const type = edge.curve?.type; + if (type === 'LINE' && start && end) { entities.push({ type: 'LINE', a: flat(start), b: flat(end) }); continue; } + const center = vec(edge.curve?.origin); + const radius = edge.curve?.radius; + if (type === 'CIRCLE' && center && typeof radius === 'number' && radius > 0) { + const c = flat(center); + const r = radius * 1000; + // A closed edge (no vertices, or both ends at one point) is a whole circle: a hole or a round part. + if (!start || !end || Math.hypot(end.x - start.x, end.y - start.y, end.z - start.z) < 1e-9) { entities.push({ type: 'CIRCLE', c, r }); continue; } + const angle = (point: Vec3) => { const [x, y] = flat(point); return degrees(Math.atan2(y - c[1], x - c[0])); }; + const from = angle(start); + const to = angle(end); + const middle = vec(edge.geometry?.midPoint); + // DXF arcs run counter-clockwise. The edge's midpoint says which way round it goes; without one, the curve + // runs counter-clockwise about its own normal from start to end. + const forward = middle ? sweep(from, angle(middle)) < sweep(from, to) : dot(vec(edge.curve?.normal) ?? face.normal, face.normal) >= 0; + entities.push({ type: 'ARC', c, r, start: forward ? from : to, end: forward ? to : from }); + continue; + } + const points = tessellated.get(id); + if (!points) return { ok: false, error: `Onshape gave no shape for a ${(type ?? 'unknown').toLowerCase()} edge of the flat face` }; + for (let index = 1; index < points.length; index++) entities.push({ type: 'LINE', a: flat(points[index - 1]), b: flat(points[index]) }); + } + if (!entities.length) return { ok: false, error: 'The flat face has no edges to cut' }; + + // Bounds: line ends, whole circles, and each arc's ends plus the quadrant points it passes. + const xs: number[] = []; + const ys: number[] = []; + const add = ([x, y]: [number, number]) => { xs.push(x); ys.push(y); }; + for (const entity of entities) { + if (entity.type === 'LINE') { add(entity.a); add(entity.b); continue; } + const at = (deg: number): [number, number] => [entity.c[0] + entity.r * Math.cos(deg * Math.PI / 180), entity.c[1] + entity.r * Math.sin(deg * Math.PI / 180)]; + if (entity.type === 'CIRCLE') { [0, 90, 180, 270].forEach(deg => add(at(deg))); continue; } + add(at(entity.start)); add(at(entity.end)); + for (const deg of [0, 90, 180, 270]) if (sweep(entity.start, deg) <= sweep(entity.start, entity.end)) add(at(deg)); + } + const [minX, minY] = [least(xs), least(ys)]; + const shift = ([x, y]: [number, number]): [number, number] => [x - minX, y - minY]; + // Plain R12 ASCII (AC1009): the oldest form every laser, router, and CAD program reads. No TABLES section, so + // the CUT layer is made by the first entity on it. $INSUNITS 4 tells programs that read it the units are mm. + const out: string[] = ['0', 'SECTION', '2', 'HEADER', '9', '$ACADVER', '1', 'AC1009', '9', '$INSUNITS', '70', '4', '0', 'ENDSEC', + '0', 'SECTION', '2', 'ENTITIES']; + const point = (code: number, [x, y]: [number, number]) => [String(code), number(x), String(code + 10), number(y), String(code + 20), '0']; + for (const entity of entities) { + if (entity.type === 'LINE') out.push('0', 'LINE', '8', 'CUT', ...point(10, shift(entity.a)), ...point(11, shift(entity.b))); + else if (entity.type === 'CIRCLE') out.push('0', 'CIRCLE', '8', 'CUT', ...point(10, shift(entity.c)), '40', number(entity.r)); + else out.push('0', 'ARC', '8', 'CUT', ...point(10, shift(entity.c)), '40', number(entity.r), '50', number(entity.start), '51', number(entity.end)); + } + out.push('0', 'ENDSEC', '0', 'EOF'); + return { ok: true, text: `${out.join('\n')}\n`, widthMm: Math.round(span(xs) * 1000) / 1000, heightMm: Math.round(span(ys) * 1000) / 1000, thicknessMm: face.thicknessMm, entities: entities.length }; +} + +/** The DXF beside a STEP file: same name, `.dxf` extension. */ +export const dxfFileName = (stepName: string) => stepName.replace(/\.step$/i, '') + '.dxf'; + +/** + * Whether body details describe a curve that is neither a line nor a circle, so the tessellated edges are needed too. + * A text search, so the Worker can decide without parsing a large response. It may ask for edges a face does not + * use (a spline elsewhere on the part); that costs one request, never a wrong drawing. + */ +export const needsTessellatedEdges = (bodyDetailsText: string) => /"(?:BCURVE|ELLIPSE|ICURVE)"/.test(bodyDetailsText); + +/** Picks the flat face and draws it: the whole DXF step, from Onshape's two responses. */ +export function drawFlatPart(details: BodyDetails, edges: TessellatedEdges | null, partId: string | null): DxfResult { + const picked = pickFlatFace(details, partId); + if (!picked.ok) return picked; + if (picked.face.needsTessellation.length && !edges) return { ok: false, error: 'Onshape gave no shape for a curved edge of the flat face' }; + return flatFaceDxf(picked.face, edges ? tessellatedPoints(edges) : new Map()); +} + +/** + * A part after the browser has drawn its DXF: the sizes and byte count filled in, or the drawing's reason for failing. + * `drawn` is null when Onshape's geometry could not be downloaded. + */ +export function withDrawnDxf

    (part: P, drawn: DxfResult | null): P { + if (!part.dxf || part.dxf.state !== 'ready') return part; + if (!drawn) return { ...part, dxf: { ...part.dxf, state: 'failed', error: 'DXF was not downloaded' } }; + if (!drawn.ok) return { ...part, dxf: { ...part.dxf, state: 'failed', error: drawn.error } }; + return { ...part, dxf: { ...part.dxf, error: null, bytes: new TextEncoder().encode(drawn.text).byteLength, widthMm: drawn.widthMm, heightMm: drawn.heightMm, thicknessMm: drawn.thicknessMm } }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0056d90..1557ed4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,8 +14,10 @@ export { batchKey, catalogReadKey, catalogVendors, clearCatalogMemoryCache, inde export type { MatchProgress } from './catalog-read'; export type { CatalogReader, IndexEntry } from './catalog-read'; export type { ControlPointAccount, ControlPointAdapter, ControlPointBomPayload, ControlPointCadExtra, ControlPointCadPart, ControlPointCandidate, ControlPointChoiceTimes, ControlPointDecision, ControlPointFile, ControlPointImport, ControlPointImportSummary, ControlPointLine, ControlPointWorkspace } from './control-point'; -export { checkStep, exportManifest, stepDispositions, stepFileName, wantsStep } from './step'; -export type { ExportPart, StepCheck } from './step'; +export { checkStep, exportManifest, stepDispositions, stepFileName, wantsDxf, wantsStep } from './step'; +export type { DxfPart, ExportPart, StepCheck } from './step'; +export { drawFlatPart, dxfFileName, flatFaceDxf, needsTessellatedEdges, pickFlatFace, tessellatedPoints, withDrawnDxf } from './dxf'; +export type { BodyDetails, DxfResult, FlatFace, TessellatedEdges } from './dxf'; export { bomColumns, bomIdentity, bomProperties, bomText, flagMissingSources, mergeBom } from './bom'; export type { BillOfMaterials, BomHeader, BomItemSource, BomProperties, BomReconciliation, BomRow } from './bom'; import type { Attribution, LicensedImage, VendorRecord } from './matching'; diff --git a/packages/shared/src/step.ts b/packages/shared/src/step.ts index a6b7aea..5712d33 100644 --- a/packages/shared/src/step.ts +++ b/packages/shared/src/step.ts @@ -4,6 +4,8 @@ import { dispositionLabel, type Disposition } from './classifier'; /** Dispositions whose parts a team makes from a solid model, so they get a STEP file. */ export const stepDispositions: Disposition[] = ['print', 'cnc', 'cut-2d', 'fabricate']; export const wantsStep = (line: BomLine) => !line.suppressed && stepDispositions.includes(line.disposition); +/** A 2D-cut part also gets a DXF of its flat face, so it can go straight to a laser, router, or waterjet. */ +export const wantsDxf = (line: Pick) => !line.suppressed && line.disposition === 'cut-2d'; /** Readable, unique, filesystem-safe name: part name, then part ID, then a short configuration tag when configured. */ export function stepFileName(line: Pick, used: Set) { @@ -41,25 +43,41 @@ export function checkStep(text: string, expectedName: string): StepCheck { return { ok: true, units: inch ? 'in' : unit ? unitNames[(unit[1] ?? '').toUpperCase()] ?? null : null, productNames, nameMatches: expected.length > 0 && productNames.some(name => normalize(name).includes(expected)) }; } +/** + * A 2D-cut part's DXF, made beside its STEP and never blocking it. In the job, `ready` means Onshape's geometry is + * stored (`edges`: tessellated edges too); the browser then draws the file and fills in the sizes, in millimetres. + */ +export type DxfPart = { fileName: string; state: 'pending' | 'ready' | 'failed'; error: string | null; bytes: number | null; widthMm: number | null; heightMm: number | null; thicknessMm: number | null; edges?: boolean }; export type ExportPart = { lineKey: string; name: string; qty: number; disposition: Disposition; partId: string | null; configuration: string | null; sourceUrl: string; fileName: string; - state: 'pending' | 'translating' | 'ready' | 'failed'; error: string | null; units: string | null; nameMatches: boolean | null; bytes: number | null }; + state: 'pending' | 'translating' | 'ready' | 'failed'; error: string | null; units: string | null; nameMatches: boolean | null; bytes: number | null; + /** Present only for 2D-cut parts. */ + dxf?: DxfPart }; /** The manifest and failure report that go in every ZIP, in plain text so any team member can read them. */ export function exportManifest(input: { assemblyName: string; documentName?: string; revision?: { type: string; id: string } | null; scannedAt: string; exportedAt: string; parts: ExportPart[] }) { const ready = input.parts.filter(part => part.state === 'ready'); const failed = input.parts.filter(part => part.state === 'failed'); + const cut = input.parts.filter(part => part.dxf); + const cutReady = cut.filter(part => part.dxf!.state === 'ready'); + const cutFailed = cut.filter(part => part.dxf!.state !== 'ready'); + const size = (value: number | null) => value === null ? '?' : String(Math.round(value * 100) / 100); const lines = [ `Control Kit STEP export`, `Assembly: ${input.assemblyName}${input.documentName ? ` (document: ${input.documentName})` : ''}`, `Revision read: ${input.revision ? `${input.revision.type === 'm' ? 'microversion' : 'version'} ${input.revision.id}` : 'unknown'}`, `Scanned: ${input.scannedAt} Exported: ${input.exportedAt}`, `Files: ${ready.length} of ${input.parts.length} parts exported${failed.length ? `, ${failed.length} failed (listed below)` : ''}`, + ...(cut.length ? [`DXF cut files: ${cutReady.length} of ${cut.length} 2D-cut parts${cutFailed.length ? `, ${cutFailed.length} failed (listed below)` : ''}`] : []), '', - 'STEP files are solid models for printing, CAM, or reference. A 2D-cut part still needs a cut file (DXF) made from its flat face before cutting.', + 'STEP files are solid models for printing, CAM, or reference.', + ...(cut.length ? ["Each 2D-cut part also gets a DXF of its largest flat face, in millimetres, drawn as seen from outside the part. Holes and outlines are on layer CUT; engraving, countersinks, and pockets are not. Check the size against the part before cutting."] : []), '', 'File | Part | Qty | Process | Units | Onshape source', ...ready.map(part => `${part.fileName} | ${part.name} | ${part.qty} | ${dispositionLabel[part.disposition]} | ${part.units ?? 'unknown'}${part.nameMatches === false ? ' (product name differs; check before use)' : ''} | ${part.sourceUrl}`), ]; + if (cutReady.length) lines.push('', 'DXF cut files (2D cut folder):', 'File | Part | Qty | Size (mm) | Thickness (mm) | Onshape source', + ...cutReady.map(part => `${part.dxf!.fileName} | ${part.name} | ${part.qty} | ${size(part.dxf!.widthMm)} x ${size(part.dxf!.heightMm)} | ${size(part.dxf!.thicknessMm)} | ${part.sourceUrl}`)); if (failed.length) lines.push('', 'Failed parts (no file in this ZIP):', ...failed.map(part => `${part.name} (qty ${part.qty}): ${part.error ?? 'unknown error'} | ${part.sourceUrl}`)); + if (cutFailed.length) lines.push('', 'DXF not made (the STEP file is unaffected):', ...cutFailed.map(part => `${part.name} (qty ${part.qty}): ${part.dxf!.error ?? 'unknown error'} | ${part.sourceUrl}`)); return `${lines.join('\n')}\n`; } diff --git a/samples/FTC_Competition_Robot_STEP.zip b/samples/FTC_Competition_Robot_STEP.zip index 6564ed11729b240907c0a2114dc22900fda451a5..4c2dd266af600c4775fa2df189d1c424fa178667 100644 GIT binary patch delta 1484 zcmbQqxl3@v5k}rKi)DH&85tN(1F_ZQi;QyhMlSKmr6u~7X1TfX;Tfr^Iq~u3Aj;BA zuec<&AT;n|RaP8NK56$RF};(V zbBf=e+_^@t$---q%7MbHwT%HQg0fE*1&F*_le9FYwIFtiZ2QNWH*fmOw)}ql?YLgs z>4o){nNNOlEI0i0FQloU;Nt5&@86zpz7|^{`IS@BujP4KP=m1KXyGO=}vo;sQ=wwO+~Vw}NS764QdDq@@;*wc?e|moPYHp9ty} z@(eoNDV`d7!B-*t*h@!{zc^%LH!*uDDt?E=r+Z7Qkhx2do@3f3>Rq0N*UY`4xr^lvl9nJr{?82`*e7{#$Z7V2Fj$e1x&EREKaNd`; z_&u}k2AqhTxA_9Ul-ys7Y=l`{Qd=q+jZ=_FU70i6KWb5O9?uxn#{4)Od z%=?=Zsj4zH$J?d7z0EVc$AXpf@?}+J4&mOTn`dmxuzcwC!tsK|&z0R_3k;*peDltU ziJG67cJRr6*@sRR;wOXOOtX%WEqgA${^P~Q^!d#jydEF#n!drMc4~9=z7mU9^`F=7 zE&G47sAb#Tq}6AvZy6r{`#aX?O}y81>o`-> z8R}M@tx_W1owrWr_m0TdUeWXFBDa1|TlQvSyPe0XA2wx7H1!T> ziZ1RqTNPFKsrl$l@6@8hGp~89ORbO7OLMi;etvsV%rncsmu=3!Z>SUB_+$T`Nn0np zIKj89)$+@ks_!k5vo%F77!_qCD77l{z6qH1IC9gkeQb6WO=63?1!5g`&SFjVnWVDu z?+b=AN*2nl%%@B=WVal1%5VMV^nQ83+<4zc6S3$mD=e3tWPN=1bj^V!#v9z7jw^&L zuWxE%Ir&CTmT_Va-?2h2C*j&4uGV{tW;-R{3}h~TAkgWPEi&Vv^+kmre97NL>}GZZ zn;%qbtz+_i<)Xkzp- zk#oL9ZOM7lPOl_J=HA9ulg`Br?l)K-E7yGGcdXygwz&99*=pzg{U2O+_kC?Vf3V1B z)xpZeuY47RXa5SSE<5n+vBTS|rY}8iO`dS+OI4TLUCrE*_KUKMtRuVU^!?0|;oYm{ z>i)d;{j|tqk&|a_tT@$sO6o9s>#f~q+C`PK*dw}^^ZY5kKroW0j<`M0ZK-8SvQI!f37U%gzlXZGWzb2EfzbN_yC z{qVuNBIn+{+Mg|BB>k^vf0}$t>+@#SH340+QA$65J~pabb10Sn5J$Ab%7DYF`xz&1 zVD3XJ`n*|mya^P20p5&EA`G~5FfbECfjjd=8Cfqp#sPCNx@m~4!oZ*oGYCvuu>eh* Z{GO3VEWn$U4J5<@go}XX1`|7o2LNzKj@bYJ delta 511 zcmVQ0;d^@p;*>tz|f1KNOU7q5YZNM}mLBfgoK!+=sZ;EW3NhGBww^Pa|l>%YO` zNlEXKskNv-f3G)UU2jjDx)Fl^QV8)3{M}8>|HUc}#$s$>1JpwegA6JD(TsYV1kPybx-5#xTc2}5282k~XmF&_M(8QP5R6a8zpS9$J*l`nal!n+;m6E!oxp}F`Rhrc3(5G01eE-$8x>9)ER9H>v zkqym*IG=B1;c1?V@sKVi!t?!c*%Y5@v4sapJl@4Gwp@#41ADaQj6o(I-28IpmzZxd z2md3= line.partId === 'motor-5203')!; -classifyScan(result, { teamNumber: null }, { [motor.bomLineKey]: 'print' }); +const wheel = result.lines.find(line => line.partId === 'wheel-96')!; +classifyScan(result, { teamNumber: null }, { [motor.bomLineKey]: 'print', [wheel.bomLineKey]: 'cut-2d' }); const mock = createMockOnshape(); const store = new Map(); const kv = { get: async (key: string) => store.get(key) ?? null, put: async (key: string, value: string) => { store.set(key, value); }, delete: async (key: string) => { store.delete(key); } }; const job = createExportJob(result, '11111111-1111-4111-8111-111111111111', new Date('2026-10-23T12:00:00Z')); -for (let round = 0; round < 10 && !job.parts.every(part => part.state === 'ready' || part.state === 'failed'); round++) { +for (let round = 0; round < 10 && !job.parts.every(part => (part.state === 'ready' || part.state === 'failed') && part.dxf?.state !== 'pending'); round++) { const budget = { remaining: 30 }; await advanceExport(job, 'sample', kv, budgeted((path, init) => mock.handle(new Request(`http://localhost:8788${path}`, init)), budget), 'v9', budget); } const files = Object.fromEntries(job.parts.flatMap((part, index) => part.state === 'ready' ? [[index, store.get(fileKey('sample', job.jobId, index))!]] : [])); -const zip = buildStepZip({ ...job, parts: job.parts, files, now: new Date('2026-10-23T12:00:00Z') }); +// The browser's step: draw each 2D-cut part's DXF from the geometry the job stored. +const dxfFiles: Record = {}; +job.parts.forEach((part, index) => { + if (part.dxf?.state !== 'ready') return; + const edges = store.get(dxfKey('sample', job.jobId, index, 'edges')); + const drawn = drawFlatPart(JSON.parse(store.get(dxfKey('sample', job.jobId, index, 'body'))!), edges ? JSON.parse(edges) : null, part.partId); + job.parts[index] = withDrawnDxf(part, drawn); + if (drawn.ok) dxfFiles[index] = drawn.text; +}); +const zip = buildStepZip({ ...job, parts: job.parts, files, dxfFiles, now: new Date('2026-10-23T12:00:00Z') }); const destination = resolve(root, 'samples', zip.filename); await writeFile(destination, zip.bytes); -console.log(`${destination} (${zip.ready} file, ${zip.failed} failure)`); +console.log(`${destination} (${zip.ready} STEP files, ${zip.dxf} DXF, ${zip.failed} failure)`); diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index a331f53..5a84988 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { expect, test, type Page } from '@playwright/test'; import { unzipSync } from 'fflate'; import { aggregateAssembly, classifyScan, type AssemblyDefinition, type ScanResult } from '../packages/shared/src/index'; +import { mockPlateBody } from '../apps/worker/src/mock-onshape'; const fixture = JSON.parse(readFileSync(new URL('../fixtures/assembly-simple.json', import.meta.url), 'utf8')) as AssemblyDefinition; const result = classifyScan(aggregateAssembly(fixture, { documentId: 'simple', workspaceOrVersion: 'w', workspaceOrVersionId: 'work1', elementId: 'elem1' }, 'Simple FTC Assembly'), { teamNumber: null }, {}); @@ -146,18 +147,23 @@ test('review explains each part, saves a one-click choice, and accepts all sugge await expect(page.getByRole('region', { name: 'Fasteners' })).toContainText('M3 Screw'); }); -test('STEP export polls the job, zips ready files with a manifest, and shows failures', async ({ page }) => { +test('STEP export polls the job, zips ready files and a 2D-cut DXF with a manifest, and shows failures', async ({ page }) => { const printed = { ...result, lines: result.lines.map((line, index) => index === 0 ? { ...line, disposition: 'print' as const } : { ...line, disposition: 'buy' as const }) }; const step = "ISO-10303-21;\nHEADER;\nENDSEC;\nDATA;\n#1=PRODUCT('Guide','Guide','',(#2));\nENDSEC;\nEND-ISO-10303-21;\n"; const parts = (state: string) => [ { lineKey: 'a', name: 'Guide', qty: 2, disposition: 'print', partId: 'p1', configuration: null, sourceUrl: 'https://cad.onshape.com/documents/a', fileName: 'Guide__p1.step', state, error: null, units: 'mm', nameMatches: true, bytes: 80 }, { lineKey: 'b', name: 'Motor', qty: 1, disposition: 'print', partId: 'p2', configuration: null, sourceUrl: 'https://cad.onshape.com/documents/b', fileName: 'Motor__p2.step', state: 'failed', error: 'Deliberate failure', units: null, nameMatches: null, bytes: null }, + { lineKey: 'c', name: 'Plate', qty: 2, disposition: 'cut-2d', partId: 'p3', configuration: null, sourceUrl: 'https://cad.onshape.com/documents/c', fileName: 'Plate__p3.step', state, error: null, units: 'mm', nameMatches: true, bytes: 80, + dxf: { fileName: 'Plate__p3.dxf', state: state === 'translating' ? 'pending' : 'ready', error: null, bytes: null, widthMm: null, heightMm: null, thicknessMm: null, edges: false } }, ]; const job = (done: boolean, state: string) => ({ jobId: '11111111-1111-4111-8111-111111111111', done, assemblyName: 'Simple FTC Assembly', scannedAt: '2026-10-24T00:00:00Z', revision: { type: 'm', id: 'mv' }, parts: parts(state) }); let polls = 0; await page.route('**/api/exports', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(job(false, 'translating')) })); await page.route('**/api/exports/11111111-1111-4111-8111-111111111111', route => { polls++; return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(job(true, 'ready')) }); }); await page.route('**/api/exports/11111111-1111-4111-8111-111111111111/files/0', route => route.fulfill({ status: 200, contentType: 'application/step', body: step })); + await page.route('**/api/exports/11111111-1111-4111-8111-111111111111/files/2', route => route.fulfill({ status: 200, contentType: 'application/step', body: step })); + // The app draws the plate's DXF from the Onshape geometry the Worker stored. + await page.route('**/api/exports/11111111-1111-4111-8111-111111111111/files/2.body', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockPlateBody('p3')) })); await connected(page, printed); await section(page, /^Make/).click(); const download = page.waitForEvent('download'); @@ -165,14 +171,18 @@ test('STEP export polls the job, zips ready files with a manifest, and shows fai const zip = await download; expect(polls).toBeGreaterThanOrEqual(1); expect(zip.suggestedFilename()).toBe('Simple_FTC_Assembly_STEP.zip'); - expect(Object.keys(unzipSync(new Uint8Array(readFileSync(await zip.path())))).sort()).toEqual(['Simple_FTC_Assembly_STEP/3D_print/Guide__p1.step', 'Simple_FTC_Assembly_STEP/MANIFEST.txt']); - await expect(page.getByRole('status').filter({ hasText: '1 of 2 STEP files downloaded' })).toBeVisible(); + const files = unzipSync(new Uint8Array(readFileSync(await zip.path()))); + expect(Object.keys(files).sort()).toEqual(['Simple_FTC_Assembly_STEP/2D_cut/Plate__p3.dxf', 'Simple_FTC_Assembly_STEP/2D_cut/Plate__p3.step', 'Simple_FTC_Assembly_STEP/3D_print/Guide__p1.step', 'Simple_FTC_Assembly_STEP/MANIFEST.txt']); + expect(new TextDecoder().decode(files['Simple_FTC_Assembly_STEP/2D_cut/Plate__p3.dxf']).startsWith('0\nSECTION')).toBe(true); + expect(new TextDecoder().decode(files['Simple_FTC_Assembly_STEP/MANIFEST.txt'])).toContain('Plate__p3.dxf | Plate | 2 | 100 x 60 | 3 |'); + await expect(page.getByRole('status').filter({ hasText: '2 of 3 STEP files and 1 of 1 DXF cut files downloaded' })).toBeVisible(); await expect(page.locator('.row').filter({ hasText: 'Motor' })).toContainText('Deliberate failure'); // When every part fails there is no ZIP, only an explanation, and each reason stays visible. await page.unroute('**/api/exports/11111111-1111-4111-8111-111111111111'); - await page.route('**/api/exports/11111111-1111-4111-8111-111111111111', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...job(true, 'failed'), parts: parts('failed').map(item => ({ ...item, state: 'failed', error: 'Deliberate failure' })) }) })); + await page.route('**/api/exports/11111111-1111-4111-8111-111111111111', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ...job(true, 'failed'), parts: parts('failed').map(item => ({ ...item, state: 'failed', error: 'Deliberate failure', ...(item.dxf ? { dxf: { ...item.dxf, state: 'failed', error: 'Deliberate DXF failure' } } : {}) })) }) })); await page.getByRole('button', { name: 'Download STEP files' }).click(); - await expect(page.getByRole('alert').filter({ hasText: 'No STEP file could be exported (2 parts failed).' })).toBeVisible(); + await expect(page.getByRole('alert').filter({ hasText: 'No STEP file could be exported (3 parts failed).' })).toBeVisible(); + await expect(page.locator('.row').filter({ hasText: 'Plate (DXF)' })).toContainText('Deliberate DXF failure'); await expect(page.locator('.row').filter({ hasText: 'Guide' })).toContainText('Deliberate failure'); }); From 55ec88f0916e166c007b1dadbfaae6e6c4a1bfd9 Mon Sep 17 00:00:00 2001 From: Sushil Muthuvelkumar <123318620+sushilm20@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:36:49 -0400 Subject: [PATCH 2/3] Flat parts over 3 mm are printed, not cut; DXF only for through-cut plates; geometry reads after STEP polls Owner rule: a DXF only for a flat part 3 mm thick or less; a thicker one is a 3D print. The classifier reads a stated thickness from the name or configuration, and the export applies the same rule to tagged plates and to the thickness measured from the body details it already reads. A person's choice wins. Greptile: reject a pocket-floor face and blind recesses so the DXF never cuts the wrong shape, and read DXF geometry after STEP starts and polls so a rate limit on it cannot cost a STEP its deadline. --- CLAUDE.md | 8 +-- apps/web/src/app/Make.tsx | 6 ++- apps/web/src/app/useControlKit.ts | 5 +- apps/worker/src/dxf.test.ts | 31 ++++++++++- apps/worker/src/mock-onshape.ts | 20 ++++--- apps/worker/src/step-export.test.ts | 8 +++ apps/worker/src/step-export.ts | 15 ++++-- packages/excel/src/step-zip.test.ts | 5 ++ packages/shared/src/classifier.ts | 10 +++- packages/shared/src/dxf.ts | 60 ++++++++++++++++++--- packages/shared/src/index.ts | 1 + packages/shared/src/step.ts | 16 ++++-- packages/shared/src/thickness.test.ts | 64 +++++++++++++++++++++++ packages/shared/src/thickness.ts | 75 +++++++++++++++++++++++++++ 14 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 packages/shared/src/thickness.test.ts create mode 100644 packages/shared/src/thickness.ts diff --git a/CLAUDE.md b/CLAUDE.md index 96f80f8..afa6890 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -280,11 +280,13 @@ REV feed schedule and catalog refresh (2026-10-28, owner request "vendor data ju DXF for flat parts (2026-10-28, owner request "do dxf for parts it detects as a flat piece"): - **Endpoint choice.** The v9 OpenAPI (`cad.onshape.com/api/openapi`, 1.222) has no field in `BTTranslateFormatParams` that picks a face: its DXF options (`sheetMetalFlat`, `flatPatternAsync`, `dxfVersion`, bend-line flags) serve sheet-metal flat patterns, which FTC plates usually are not, and forum answers about face DXF through the translation API are unresolved. So Control Kit draws the DXF itself from the documented `GET /api/v9/parts/d/{did}/{w|v}/{wvid}/e/{eid}/partid/{pid}/bodydetails?configuration=…&includeGeometricData=true` (Part/getBodyDetails, OAuth2Read, synchronous): faces with `area`, plane `normal`/`origin`/`isOrientedWithFace`, loops of coedges, and edges with `curve` (LINE, CIRCLE with `radius`) and `geometry` (`startPoint`, `endPoint`, `midPoint`), in metres. Only when the text names a BCURVE, ELLIPSE, or ICURVE does it also read `GET …/partid/{pid}/tessellatededges?configuration=…` (Part/getEdges) for the whole part. Both use the STEP's own `exportSource` and configuration. -- **Cost.** One billable request per 2D-cut part (two with a spline or ellipse anywhere on it), on top of its STEP's start, polls, and download. Counted in the ledger as `body-details` and `tessellated-edges` under purpose `export`, inside the same per-poll budget (`pollBudget` 12); a DXF starts only with at least two requests left in the poll. No translation, so no polling. -- **Face.** `pickFlatFace` (`packages/shared/src/dxf.ts`) takes the solid body (the one whose id is the part id when there are several) and its largest planar face with an outer loop; a blind pocket or countersink shrinks its own side, so the through profile wins. `flatFaceDxf` draws it seen from outside the part (outward normal toward the viewer), x along the longest straight outer edge, origin at the lower-left corner, in mm: LINE, exact ARC (direction from the edge's `midPoint`, else the curve normal), CIRCLE for closed circular edges, and tessellated segments for anything else. R12 ASCII (`AC1009`) with `$INSUNITS` 4 (mm), everything on layer `CUT`, no TABLES section. Thickness is measured along the normal over the body's points, for the manifest. +- **Cost.** One billable request per 2D-cut part (two with a spline or ellipse anywhere on it), on top of its STEP's start, polls, and download. Counted in the ledger as `body-details` and `tessellated-edges` under purpose `export`, inside the same per-poll budget (`pollBudget` 12). Geometry reads come after STEP starts and polls in each poll, so a rate limit on them never costs a STEP its deadline, and one starts only with at least two requests left. No translation, so no polling. +- **Face.** `pickFlatFace` (`packages/shared/src/dxf.ts`) takes the solid body (the one whose id is the part id when there are several) and its largest planar face with an outer loop; a countersink shrinks its own side, so the through profile wins. `flatFaceDxf` draws it seen from outside the part (outward normal toward the viewer), x along the longest straight outer edge, origin at the lower-left corner, in mm: LINE, exact ARC (direction from the edge's `midPoint`, else the curve normal), CIRCLE for closed circular edges, and tessellated segments for anything else. R12 ASCII (`AC1009`) with `$INSUNITS` 4 (mm), everything on layer `CUT`, no TABLES section. Thickness is measured along the normal over the body's points, for the manifest. - **Where it runs.** Parsing and drawing a 400-hole plate took 10–26 ms in Node, over the free plan's 10 ms with several plates per poll, so the Worker only fetches and stores Onshape's JSON (`export-file:{userId}:{jobId}:{index}:body` and `:edges`, 1-hour TTL; a text search, no parse) and serves it at `GET /api/exports/{jobId}/files/{index}.body|.edges`. The browser draws with `drawFlatPart` and fills sizes with `withDrawnDxf` before building the ZIP, as matching moved to the browser in PR 7f. - **Job and files.** `ExportPart.dxf` (`DxfPart`: fileName, state, error, bytes, sizes, `edges`) exists only for `wantsDxf` lines (active cut-2d). A job is done only when no DXF is pending. A DXF failure is the DXF's alone: the STEP carries on, the app lists it as "{part} (DXF)", and MANIFEST.txt has a "DXF not made (the STEP file is unaffected)" section. The DXF sits beside the STEP in `2D_cut/` as `dxfFileName(stepFileName)`; a ZIP with only DXFs is still made. The manifest lists each DXF with its size and thickness. The workbook's Make sheet gains a "DXF file" column after "STEP file" (9 columns). `pnpm sample:step` now sets the fixture wheel to 2D cut, so the committed sample carries a DXF. -- **Mock.** `mockPlateBody` in `mock-onshape.ts` is a 100 × 60 × 3 mm plate (one fillet arc, a 10 mm hole, optional spline) shaped like `BTExportModelBodiesResponse`; vectors are read as `{x, y, z}` or arrays. +- **Through check.** A cut file must be a plate's outside with every loop running through. `runsThrough` rejects a largest face with body points beyond it (a pocket floor), a part with no opposite-facing flat face at its far extreme, and any loop whose walls do not connect, face to face through the edge-sharing in the loops, to that far face (a blind recess, which would otherwise be cut as a hole). A recess with a through hole in its floor still passes; the manifest says to check the size before cutting. +- **Thickness rule (owner, 2026-10-28: "it only does the dxf if the panel is like 3mm or less, otherwise it should be 3dp step file").** A flat part is cut from sheet with a DXF only at 3 mm or less (`cutsFromSheet`, 0.005 mm tolerance for float noise; 1/8 in = 3.175 mm prints). `statedThicknessMm` (`packages/shared/src/thickness.ts`) reads a stated thickness: a configuration value whose key names thickness (`Thickness=3+mm`, URL-decoded), "6mm thick" / "thickness: 1/4 in" / "t=2.5mm", the smallest of "300x200x3mm", or else the one length of 25.4 mm or less in the name, description, and configuration (inches and fractions converted; "100x60mm" sizes ignored; two different short lengths are ambiguous). `decideDisposition` then makes a flat plate over 3 mm `print` with evidence "flat, 6 mm thick: over 3 mm, so 3D printed" (or "… 3 mm or less, so cut from sheet"). The export applies the same rule (`thicknessRule`) to a cut-2d part not chosen by a person, e.g. one set by a team tag: it is filed as a print with that `note` and no DXF. When nothing states the thickness, the export measures it from the body details it already reads (no extra request): `withDrawnDxf` turns a part over 3 mm into a print, moves its STEP to `3D_print/`, drops the DXF, and adds the note. Notes appear in the Make panel (an "Export notes" list) and in MANIFEST.txt under "Notes:". A person's manual choice always wins (`ExportPart.manual`). +- **Mock.** `mockPlateBody` in `mock-onshape.ts` is a 100 × 60 mm plate, 3 mm thick unless given (one fillet arc, a 10 mm hole, side walls, optional spline) shaped like `BTExportModelBodiesResponse`; vectors are read as `{x, y, z}` or arrays. - **Not verified live** (no Onshape request was made): the real body details JSON shape (vector form, `midPoint` on arcs, `isOrientedWithFace`, `area` units), whether linked-document parts need `linkDocumentId`, the response size and Worker time for a large plate, and that the DXF opens at the right size in LightBurn, Inkscape, or a router's CAM. ## Tuning, tests, and release diff --git a/apps/web/src/app/Make.tsx b/apps/web/src/app/Make.tsx index c4f40d2..ca61f84 100644 --- a/apps/web/src/app/Make.tsx +++ b/apps/web/src/app/Make.tsx @@ -21,7 +21,7 @@ export function Make({ kit, open, go }: { kit: ControlKit; open: (key: string) =

    -

    STEP files

    For slicers and CAM. Each 2D-cut part also gets a DXF of its flat face, in millimetres, ready for a laser, router, or waterjet.

    +

    STEP files

    For slicers and CAM. Each 2D-cut part 3 mm thick or less also gets a DXF of its flat face, in millimetres, ready for a laser, router, or waterjet; a thicker flat part is 3D printed.

    {step?.running &&
    @@ -31,6 +31,10 @@ export function Make({ kit, open, go }: { kit: ControlKit; open: (key: string) = {step && !step.running && step.zipMade !== null &&
    {step.zipMade ? {step.failures.length ? 'The parts below failed. They are also listed in MANIFEST.txt in the ZIP.' : 'Every part exported and passed its file check.'} : No ZIP was made. Each part and its reason is listed below.} + {(step.notes?.length ?? 0) > 0 &&
    {step.notes!.map(note =>
    +
    {note.name}{note.reason}
    + +
    )}
    } {step.failures.length > 0 &&
    {step.failures.map(failure =>
    {failure.name}{failure.reason}
    diff --git a/apps/web/src/app/useControlKit.ts b/apps/web/src/app/useControlKit.ts index 06e162d..4540be0 100644 --- a/apps/web/src/app/useControlKit.ts +++ b/apps/web/src/app/useControlKit.ts @@ -10,6 +10,8 @@ export type ScanStep = 'revision' | 'assembly' | 'bom' | 'choices' | 'catalogs' export type ScanCounts = { parts?: number; subassemblies?: number; bomRows?: number; cached?: boolean; lines?: number; catalogs?: number; catalogsTotal?: number; products?: number; matched?: number }; export type ScanProgress = { stage: string; current: number; total: number; instances: number; retry?: string; step?: ScanStep; counts?: ScanCounts }; export type ExportState = { running: boolean; ready: number; failed: number; total: number; failures: { name: string; reason: string; url: string }[]; zipMade: boolean | null; + /** Parts the export changed how they are made, with why (a flat part over 3 mm thick is printed). */ + notes?: { name: string; reason: string; url: string }[]; /** DXF cut files for 2D-cut parts: how many arrived of how many were wanted. */ dxfReady?: number; dxfTotal?: number }; type ScanEvent = @@ -590,7 +592,8 @@ export function useControlKit(context: URLSearchParams) { // Failures are recorded before the ZIP is built, so an export where every part failed still lists each one. // The export counts as running, and the ZIP as not yet made, until the browser has been handed the file. const dxfTotal = job.parts.filter(part => part.dxf).length; - setStep({ running: true, ready, failed: stepFailures.length, total: job.parts.length, failures, zipMade: null, dxfReady: dxfTotal - dxfFailures.length, dxfTotal }); + const notes = job.parts.filter(part => part.note).map(part => ({ name: part.name, reason: part.note!, url: part.sourceUrl })); + setStep({ running: true, ready, failed: stepFailures.length, total: job.parts.length, failures, notes, zipMade: null, dxfReady: dxfTotal - dxfFailures.length, dxfTotal }); const { buildStepZip } = await exporters(); if (!active()) return; const zip = buildStepZip({ ...job, files, dxfFiles }); diff --git a/apps/worker/src/dxf.test.ts b/apps/worker/src/dxf.test.ts index 6d75f25..f4c5b20 100644 --- a/apps/worker/src/dxf.test.ts +++ b/apps/worker/src/dxf.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { dxfFileName, flatFaceDxf, pickFlatFace, tessellatedPoints, type BodyDetails } from '@control-kit/shared'; +import { drawFlatPart, dxfFileName, flatFaceDxf, pickFlatFace, tessellatedPoints, withDrawnDxf, type BodyDetails, type ExportPart } from '@control-kit/shared'; import { mockPlateBody, mockPlateEdges } from './mock-onshape'; /** DXF group codes and values, paired. */ @@ -71,6 +71,35 @@ describe('DXF of a flat face', () => { expect(pickFlatFace(missing, 'plate')).toEqual({ ok: false, error: 'Onshape body details name an edge they do not describe' }); }); + it('refuses a face that is a pocket floor, and a blind recess that would be cut as a hole', () => { + // A pocket floor: part of the body lies beyond the largest face. + const floor = mockPlateBody('plate'); + floor.bodies[0].vertices.push({ id: 'v3', point: { x: 0, y: 0, z: -0.002 } }); + expect(pickFlatFace(floor, 'plate')).toEqual({ ok: false, error: 'The largest flat face is inside the part (a pocket floor), so it is not a cut outline' }); + // The 10 mm "hole" is a recess: its wall reaches only a floor inside the plate, never the far face. + const recess = mockPlateBody('plate'); + const faces = recess.bodies[0].faces as Array<{ id: string; loops: Array<{ coedges: Array<{ edgeId: string }> }> }>; + faces.find(face => face.id === 'hole')!.loops[0].coedges = [{ edgeId: 'e6' }, { edgeId: 'f6' }]; + faces.push({ id: 'floor', loops: [{ coedges: [{ edgeId: 'f6' }] }] }); + expect(pickFlatFace(recess, 'plate')).toEqual({ ok: false, error: 'A recess on the flat face does not run through the part, so it would be cut as a hole' }); + // With no face on the far side it is not a plate at all. + const open = mockPlateBody('plate'); + open.bodies[0].faces = open.bodies[0].faces.filter(face => face.id !== 'top'); + expect(pickFlatFace(open, 'plate')).toEqual({ ok: false, error: 'No flat face on the other side of the part, so it is not a plate' }); + }); + + it('prints a flat part the geometry shows is over 3 mm thick, unless a person chose 2D cut', () => { + const part: ExportPart = { lineKey: 'k', name: 'Side Plate', qty: 2, disposition: 'cut-2d', partId: 'plate', configuration: null, sourceUrl: 'https://cad.onshape.com/x', fileName: 'Side_Plate__plate.step', + state: 'ready', error: null, units: 'mm', nameMatches: true, bytes: 10, dxf: { fileName: 'Side_Plate__plate.dxf', state: 'ready', error: null, bytes: null, widthMm: null, heightMm: null, thicknessMm: null, edges: false } }; + const at = (metres: number) => drawFlatPart(mockPlateBody('plate', false, metres), null, 'plate'); + expect(withDrawnDxf(part, at(0.003))).toMatchObject({ disposition: 'cut-2d', dxf: { state: 'ready', thicknessMm: 3 } }); + const thick = withDrawnDxf(part, at(0.0032)); + expect(thick).toMatchObject({ disposition: 'print', note: 'flat, 3.2 mm thick: over 3 mm, so 3D printed' }); + expect(thick.dxf).toBeUndefined(); + expect(withDrawnDxf(part, at(0.003175))).toMatchObject({ disposition: 'print', note: 'flat, 3.18 mm thick: over 3 mm, so 3D printed' }); + expect(withDrawnDxf({ ...part, manual: true }, at(0.006))).toMatchObject({ disposition: 'cut-2d', dxf: { state: 'ready', thicknessMm: 6 } }); + }); + it('names the DXF after its STEP file', () => { expect(dxfFileName('Side_Plate__JHD.step')).toBe('Side_Plate__JHD.dxf'); expect(dxfFileName('Side_Plate__JHD_2.step')).toBe('Side_Plate__JHD_2.dxf'); diff --git a/apps/worker/src/mock-onshape.ts b/apps/worker/src/mock-onshape.ts index 2b55fc4..cc6c3a4 100644 --- a/apps/worker/src/mock-onshape.ts +++ b/apps/worker/src/mock-onshape.ts @@ -52,11 +52,12 @@ END-ISO-10303-21; `; /** - * Body details for a 3 mm plate, 100 x 60 mm, in metres as Onshape reports them: one rounded corner (an arc), a - * 10 mm hole (a circle), and with `curved` a spline edge that needs Onshape's tessellation. The bottom face is the - * largest; the top is smaller because of a countersink. Shaped like BTExportModelBodiesResponse. + * Body details for a 100 x 60 mm plate, 3 mm thick unless `thickness` (metres) says otherwise, in metres as Onshape + * reports them: one rounded corner (an arc), a 10 mm hole (a circle), side walls joining both faces, and with + * `curved` a spline edge that needs Onshape's tessellation. The bottom face is the largest; the top is smaller + * because of a countersink. Shaped like BTExportModelBodiesResponse. */ -export function mockPlateBody(partId: string, curved = false) { +export function mockPlateBody(partId: string, curved = false, thickness = 0.003) { const p = (x: number, y: number, z = 0) => ({ x, y, z }); const line = (id: string, a: ReturnType, b: ReturnType) => ({ id, curve: { type: 'LINE', origin: a, direction: p(b.x - a.x, b.y - a.y) }, geometry: { startPoint: a, endPoint: b, midPoint: p((a.x + b.x) / 2, (a.y + b.y) / 2) }, vertices: [] }); const r = Math.SQRT1_2 * 0.01; @@ -67,13 +68,16 @@ export function mockPlateBody(partId: string, curved = false) { curved ? { id: 'e4', curve: { type: 'BCURVE' }, geometry: { startPoint: p(0.09, 0.06), endPoint: p(0, 0.06) }, vertices: [] } : line('e4', p(0.09, 0.06), p(0, 0.06)), line('e5', p(0, 0.06), p(0, 0)), { id: 'e6', curve: { type: 'CIRCLE', origin: p(0.03, 0.03), normal: p(0, 0, 1), radius: 0.005 }, geometry: { startPoint: p(0.035, 0.03), endPoint: p(0.035, 0.03) }, vertices: [] }, - line('t1', p(0, 0, 0.003), p(0.1, 0, 0.003)), + line('t1', p(0, 0, thickness), p(0.1, 0, thickness)), ]; const loop = (outer: boolean, ids: string[]) => ({ isOuter: outer, isInner: !outer, coedges: ids.map(edgeId => ({ edgeId, orientation: true })) }); - return { bodies: [{ id: partId, type: 'SOLID', edges, vertices: [{ id: 'v1', point: p(0, 0, 0) }, { id: 'v2', point: p(0, 0, 0.003) }], faces: [ + return { bodies: [{ id: partId, type: 'SOLID', edges, vertices: [{ id: 'v1', point: p(0, 0, 0) }, { id: 'v2', point: p(0, 0, thickness) }], faces: [ { id: 'bottom', area: 0.0059, surface: { type: 'PLANE', origin: p(0, 0, 0), normal: p(0, 0, -1), isOrientedWithFace: true }, loops: [loop(true, ['e1', 'e2', 'e3', 'e4', 'e5']), loop(false, ['e6'])] }, - { id: 'top', area: 0.0058, surface: { type: 'PLANE', origin: p(0, 0, 0.003), normal: p(0, 0, 1), isOrientedWithFace: true }, loops: [loop(true, ['t1'])] }, - { id: 'hole', area: 0.0001, surface: { type: 'CYLINDER', origin: p(0.03, 0.03), axis: p(0, 0, 1), radius: 0.005 }, loops: [] }, + { id: 'top', area: 0.0058, surface: { type: 'PLANE', origin: p(0, 0, thickness), normal: p(0, 0, 1), isOrientedWithFace: true }, loops: [loop(true, ['t1', 't2', 't3', 't4', 't5']), loop(false, ['t6'])] }, + // Side walls and the hole's wall join each bottom edge to the top face (top edges t2-t6 are named only by the loops). + ...['1', '2', '4', '5'].map(n => ({ id: `side${n}`, area: 0.0003, surface: { type: 'PLANE', origin: p(0, 0, 0), normal: p(1, 0, 0), isOrientedWithFace: true }, loops: [loop(true, [`e${n}`, `t${n}`])] })), + { id: 'fillet', area: 0.00005, surface: { type: 'CYLINDER', origin: p(0.09, 0.05), axis: p(0, 0, 1), radius: 0.01 }, loops: [loop(true, ['e3', 't3'])] }, + { id: 'hole', area: 0.0001, surface: { type: 'CYLINDER', origin: p(0.03, 0.03), axis: p(0, 0, 1), radius: 0.005 }, loops: [loop(true, ['e6', 't6'])] }, ] }] }; } /** Onshape's tessellation of the curved plate's spline edge: a gentle bow from (90, 60) to (0, 60) mm. */ diff --git a/apps/worker/src/step-export.test.ts b/apps/worker/src/step-export.test.ts index 8e34024..30927d1 100644 --- a/apps/worker/src/step-export.test.ts +++ b/apps/worker/src/step-export.test.ts @@ -166,6 +166,14 @@ describe('DXF for 2D-cut parts', () => { expect(work.parts[0].dxf!.state).toBe('pending'); expect(jobSummary(work).done).toBe(false); }); + it('files a tagged 2D-cut plate whose name says it is over 3 mm thick as a print, with no DXF, unless a person chose it', () => { + const work = job(2, n => n ? '6mm Side Plate' : '6mm Side Plate [laser]'); + expect(work.parts[0]).toMatchObject({ disposition: 'print', note: 'flat, 6 mm thick: over 3 mm, so 3D printed' }); + expect(work.parts[0].dxf).toBeUndefined(); + // Without a tag the classifier already decided print. + expect(work.parts[1]).toMatchObject({ disposition: 'print' }); + expect(jobSummary(work).parts[0]).toMatchObject({ note: 'flat, 6 mm thick: over 3 mm, so 3D printed' }); + }); it('gives a part with no export source a failed DXF too, and other processes none', () => { expect(job(1).parts[0].dxf).toBeUndefined(); const result = aggregateAssembly({ rootAssembly: { instances: [{ id: 'i', name: 'Side Plate', type: 'Part', documentId: 'doc', elementId: 'studio', partId: 'p0' }] } }, diff --git a/apps/worker/src/step-export.ts b/apps/worker/src/step-export.ts index 32d51fe..173c8c3 100644 --- a/apps/worker/src/step-export.ts +++ b/apps/worker/src/step-export.ts @@ -1,4 +1,4 @@ -import { checkStep, dxfFileName, needsTessellatedEdges, stepFileName, wantsDxf, wantsStep, type DxfPart, type ExportPart, type ScanResult } from '@control-kit/shared'; +import { checkStep, dxfFileName, needsTessellatedEdges, stepFileName, thicknessRule, wantsDxf, wantsStep, type DxfPart, type ExportPart, type ScanResult } from '@control-kit/shared'; import type { Kv } from './index'; import { SessionExpiredError } from './scan'; @@ -24,7 +24,7 @@ type FetchOnshape = (path: string, init?: RequestInit) => Promise; export const jobSummary = (job: ExportJob) => ({ jobId: job.jobId, assemblyName: job.assemblyName, documentName: job.documentName, revision: job.revision, scannedAt: job.scannedAt, createdAt: job.createdAt, done: job.parts.every(part => (part.state === 'ready' || part.state === 'failed') && part.dxf?.state !== 'pending'), rateLimited: Boolean(job.rateLimitedAt && Date.now() - job.rateLimitedAt < 60000), - parts: job.parts.map(({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes, dxf }) => ({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes, ...(dxf ? { dxf } : {}) })) }); + parts: job.parts.map(({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes, dxf, manual, note }) => ({ lineKey, name, qty, disposition, partId, configuration, sourceUrl, fileName, state, error, units, nameMatches, bytes, ...(dxf ? { dxf } : {}), ...(manual ? { manual } : {}), ...(note ? { note } : {}) })) }); export const jobKey = (userId: string, jobId: string) => `export:${userId}:${jobId}`; export const fileKey = (userId: string, jobId: string, index: number) => `export-file:${userId}:${jobId}:${index}`; @@ -40,8 +40,11 @@ export function createExportJob(result: ScanResult, jobId: string, now = new Dat const missing = !line.partId ? 'Part ID unavailable' : !line.exportSource ? 'No workspace or version to export from (linked part without a version)' : null; const fileName = stepFileName(line, used); const dxf: DxfPart | undefined = wantsDxf(line) ? { fileName: dxfFileName(fileName), state: missing ? 'failed' : 'pending', error: missing, bytes: null, widthMm: null, heightMm: null, thicknessMm: null } : undefined; - return { lineKey: line.bomLineKey, name: line.name, qty: line.qty, disposition: line.disposition, partId: line.partId, configuration: line.configuration, sourceUrl: line.partStudioUrl, fileName, + // A 2D-cut part whose stated thickness is over 3 mm is printed instead (the owner's rule; a person's choice wins). + const { disposition, note } = thicknessRule(line); + return { lineKey: line.bomLineKey, name: line.name, qty: line.qty, disposition, partId: line.partId, configuration: line.configuration, sourceUrl: line.partStudioUrl, fileName, state: missing ? 'failed' : 'pending', error: missing, units: null, nameMatches: null, bytes: null, ...(dxf ? { dxf } : {}), + ...(line.dispositionSource === 'manual' ? { manual: true } : {}), ...(note ? { note } : {}), documentId: line.documentId, elementId: line.elementId, exportSource: line.exportSource ?? null, translationId: null, resultDocumentId: null, startedAt: null }; }); return { jobId, documentId: result.documentId, scanId: result.scanId!, assemblyName: result.assemblyName, documentName: result.documentName, revision: result.diagnostics?.revision ?? null, @@ -78,8 +81,10 @@ export async function advanceExport(job: ExportJob, userId: string, kv: Kv, fetc const cutting = job.parts.flatMap((part, index) => part.dxf?.state === 'pending' ? [index] : []); const start = running.length ? (job.cursor ?? 0) % running.length : 0; let polled = 0; - // STEP translations start first (Onshape runs them in parallel), then flat parts' geometry is read for their DXFs, then running translations are polled. - const tasks = [...pending.map(index => ({ index, dxf: false })), ...cutting.map(index => ({ index, dxf: true })), ...[...running.slice(start), ...running.slice(0, start)].map(index => ({ index, dxf: false }))]; + // STEP translations start first (Onshape runs them in parallel) and running ones are polled next, so their + // deadline is never spent waiting on DXF geometry; flat parts' geometry is read with whatever budget is left, and a + // rate limit on it ends only the rest of this poll. + const tasks = [...pending.map(index => ({ index, dxf: false })), ...[...running.slice(start), ...running.slice(0, start)].map(index => ({ index, dxf: false })), ...cutting.map(index => ({ index, dxf: true }))]; for (const { index, dxf } of tasks) { if (budget.remaining <= 0) break; const part = job.parts[index]; diff --git a/packages/excel/src/step-zip.test.ts b/packages/excel/src/step-zip.test.ts index b227bc0..7b9061c 100644 --- a/packages/excel/src/step-zip.test.ts +++ b/packages/excel/src/step-zip.test.ts @@ -65,6 +65,11 @@ describe('STEP ZIP', () => { expect(manifest).toContain('Gusset (qty 2): This part has no flat face to cut from'); expect(manifest).toContain('Arm (qty 2): DXF was not downloaded'); expect(zip).toMatchObject({ ready: 3, failed: 1, dxf: 2 }); + // A flat part found over 3 mm thick is filed as a print, with its reason in the manifest. + const printed = buildStepZip({ assemblyName: 'Robot', scannedAt: '', parts: [part('Thick Plate', 'ready', { disposition: 'print', fileName: 'Thick__p5.step', note: 'flat, 6 mm thick: over 3 mm, so 3D printed' })], files: { 0: step('Thick Plate') } }); + const printedFiles = unzipSync(printed.bytes); + expect(Object.keys(printedFiles)).toContain('Robot_STEP/3D_print/Thick__p5.step'); + expect(strFromU8(printedFiles['Robot_STEP/MANIFEST.txt'])).toContain('Notes:\nThick Plate: flat, 6 mm thick: over 3 mm, so 3D printed'); // A DXF alone still makes a ZIP: the cut file is useful without its STEP. const only = buildStepZip({ assemblyName: 'Robot', scannedAt: '', parts: [parts[2]], files: {}, dxfFiles: { 0: cut } }); expect(Object.keys(unzipSync(only.bytes))).toContain('Robot_STEP/2D_cut/Bracket__p3.dxf'); diff --git a/packages/shared/src/classifier.ts b/packages/shared/src/classifier.ts index 5c27b75..497df4d 100644 --- a/packages/shared/src/classifier.ts +++ b/packages/shared/src/classifier.ts @@ -1,5 +1,6 @@ import type { BomLine, ScanResult } from './index'; import { recalculatePurchasing } from './purchasing'; +import { cutsFromSheet, formatMm, maxCutThicknessMm, statedThicknessMm, tooThickText } from './thickness'; /** * How the team will get the physical part. `review` means nothing reliable decided it yet. @@ -152,10 +153,15 @@ export function decideDisposition(line: BomLine, teamNumber: string | null, asse } // "Screw Mount Bracket" or "Nut Holder" is a part the team makes: custom-part words keep hardware wording from buying it. if (hardwareWords.test(text) && !customWords.test(text)) return auto('buy', 'medium', 'standard hardware wording in name'); - // A plate the name says is machined ("CNC milled side plate") is CNC; any other plate is 2D cut. + // A plate the name says is machined ("CNC milled side plate") is CNC. Any other plate is 2D cut when it is 3 mm + // thick or less, or its thickness is not stated; a stated thickness over 3 mm makes it a 3D print (owner's rule). if (flatWords.test(text) && provenance !== 'linked-library') { const machined = /\b(?:cnc|mill(?:ed|ing)?|machined|lathe)\b/i.test(text); - return { ...auto(machined ? 'cnc' : 'cut-2d', 'low', machined ? 'flat part with machining wording in name' : 'flat-part wording in name'), evidence: [...base.evidence, machined ? 'flat part with machining wording in name' : 'flat-part wording in name', ...(team ? [team] : []), 'decided automatically; change it if wrong'] }; + const stated = machined ? null : statedThicknessMm(line); + const disposition = machined ? 'cnc' : stated && !cutsFromSheet(stated.mm) ? 'print' : 'cut-2d'; + const why = machined ? 'flat part with machining wording in name' : disposition === 'print' ? tooThickText(stated!.mm) + : stated ? `flat, ${formatMm(stated.mm)} mm thick: ${maxCutThicknessMm} mm or less, so cut from sheet` : 'flat-part wording in name'; + return { ...auto(disposition, 'low', why), evidence: [...base.evidence, why, ...(team ? [team] : []), 'decided automatically; change it if wrong'] }; } if (provenance === 'team-design' && (customWords.test(text) || team)) return { ...base, disposition: 'review', source: 'suggestion', confidence: 'low', suggestion: 'print', evidence: [...base.evidence, ...(team ? [team] : ['custom-part wording in name'])], reviewReason: 'Team-designed part: choose how it is made' }; // Parts linked from another document (vendor and parts-library CAD) are almost always bought. diff --git a/packages/shared/src/dxf.ts b/packages/shared/src/dxf.ts index 32b04d8..0a6b1fa 100644 --- a/packages/shared/src/dxf.ts +++ b/packages/shared/src/dxf.ts @@ -9,7 +9,8 @@ * several milliseconds to parse and draw and the free plan gives a Worker request 10 ms of CPU. */ -import type { DxfPart } from './step'; +import type { ExportPart } from './step'; +import { cutsFromSheet, tooThickText } from './thickness'; export type Vec3 = { x: number; y: number; z: number }; /** Onshape writes vectors as `{x, y, z}`; arrays are accepted too, so a different serialization fails nothing. */ @@ -73,9 +74,52 @@ export function pickFlatFace(details: BodyDetails, partId: string | null): { ok: const missing = loops.flatMap(loop => loop.edgeIds).find(id => !edges.has(id)); if (missing) return { ok: false, error: 'Onshape body details name an edge they do not describe' }; const needsTessellation = [...new Set(loops.flatMap(loop => loop.edgeIds).filter(id => !['LINE', 'CIRCLE'].includes(edges.get(id)!.curve?.type ?? '')))]; - const along = bodyPoints(body).map(point => dot(sub(point, best!.origin), best!.normal)); - const thicknessMm = along.length ? Math.round(span(along) * 1000 * 1000) / 1000 : null; - return { ok: true, face: { faceId: best.face.id ?? '', areaMm2: best.area * 1e6, normal: best.normal, origin: best.origin, body, loops, needsTessellation, thicknessMm } }; + const through = runsThrough(body, best.face, best.normal, best.origin, loops); + if (!through.ok) return through; + return { ok: true, face: { faceId: best.face.id ?? '', areaMm2: best.area * 1e6, normal: best.normal, origin: best.origin, body, loops, needsTessellation, thicknessMm: through.thicknessMm } }; +} + +/** + * A cut file is only right when the face is a plate's outside and every loop on it runs through to the other side. + * The face must be at the part's extreme along its normal (a pocket floor is not), a flat face must face the other + * way at the far extreme, and each loop's walls must connect, face to face, to that far face: a blind recess on the + * face reaches only its own floor and walls, so it would be wrongly cut as a hole. A recess whose floor has a + * through hole still passes; that case is left to the manifest's "check before cutting". + */ +function runsThrough(body: BodyDetailsBody, chosen: BodyDetailsFace, normal: Vec3, origin: Vec3, loops: FlatFace['loops']): { ok: true; thicknessMm: number | null } | { ok: false; error: string } { + const tolerance = 1e-6; + const along = bodyPoints(body).map(point => dot(sub(point, origin), normal)); + if (!along.length) return { ok: true, thicknessMm: null }; + if (along.some(value => value > tolerance)) return { ok: false, error: 'The largest flat face is inside the part (a pocket floor), so it is not a cut outline' }; + const depth = least(along); + const faces = body.faces ?? []; + const far = new Set(faces.filter(face => { + if (face === chosen || face.surface?.type !== 'PLANE') return false; + const raw = unit(vec(face.surface.normal) ?? { x: 0, y: 0, z: 0 }); + const at = vec(face.surface.origin); + const facing = raw && (face.surface.isOrientedWithFace === false ? scale(raw, -1) : raw); + return facing && at && dot(facing, normal) < -0.999999 && Math.abs(dot(sub(at, origin), normal) - depth) < tolerance; + })); + if (!far.size) return { ok: false, error: 'No flat face on the other side of the part, so it is not a plate' }; + const facesOf = new Map(); + for (const face of faces) for (const loop of face.loops ?? []) for (const coedge of loop.coedges ?? []) { + if (coedge.edgeId) facesOf.set(coedge.edgeId, [...(facesOf.get(coedge.edgeId) ?? []), face]); + } + const edgesOf = (face: BodyDetailsFace) => (face.loops ?? []).flatMap(loop => (loop.coedges ?? []).flatMap(coedge => coedge.edgeId ? [coedge.edgeId] : [])); + for (const loop of loops) { + const seen = new Set([chosen]); + const queue = loop.edgeIds.flatMap(id => facesOf.get(id) ?? []).filter(face => !seen.has(face)); + let reached = false; + while (queue.length && !reached) { + const face = queue.shift()!; + if (seen.has(face)) continue; + seen.add(face); + if (far.has(face)) { reached = true; break; } + for (const id of edgesOf(face)) for (const next of facesOf.get(id) ?? []) if (!seen.has(next)) queue.push(next); + } + if (!reached) return { ok: false, error: loop.outer ? 'The outline does not run through the part, so it is not a plain plate' : 'A recess on the flat face does not run through the part, so it would be cut as a hole' }; + } + return { ok: true, thicknessMm: Math.round(-depth * 1000 * 1000) / 1000 }; } /** Tessellated points by edge ID, from Onshape's tessellated edges response. */ @@ -201,10 +245,14 @@ export function drawFlatPart(details: BodyDetails, edges: TessellatedEdges | nul /** * A part after the browser has drawn its DXF: the sizes and byte count filled in, or the drawing's reason for failing. - * `drawn` is null when Onshape's geometry could not be downloaded. + * `drawn` is null when Onshape's geometry could not be downloaded. A part the geometry shows is over 3 mm thick, and + * that no person set to 2D cut, becomes a print with a note and no DXF (the owner's thickness rule). */ -export function withDrawnDxf

    (part: P, drawn: DxfResult | null): P { +export function withDrawnDxf

    (part: P, drawn: DxfResult | null): P { if (!part.dxf || part.dxf.state !== 'ready') return part; + if (drawn?.ok && drawn.thicknessMm !== null && !cutsFromSheet(drawn.thicknessMm) && !part.manual) { + return { ...part, disposition: 'print', dxf: undefined, note: tooThickText(drawn.thicknessMm) }; + } if (!drawn) return { ...part, dxf: { ...part.dxf, state: 'failed', error: 'DXF was not downloaded' } }; if (!drawn.ok) return { ...part, dxf: { ...part.dxf, state: 'failed', error: drawn.error } }; return { ...part, dxf: { ...part.dxf, error: null, bytes: new TextEncoder().encode(drawn.text).byteLength, widthMm: drawn.widthMm, heightMm: drawn.heightMm, thicknessMm: drawn.thicknessMm } }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1557ed4..e9136d7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,7 @@ export type { CatalogReader, IndexEntry } from './catalog-read'; export type { ControlPointAccount, ControlPointAdapter, ControlPointBomPayload, ControlPointCadExtra, ControlPointCadPart, ControlPointCandidate, ControlPointChoiceTimes, ControlPointDecision, ControlPointFile, ControlPointImport, ControlPointImportSummary, ControlPointLine, ControlPointWorkspace } from './control-point'; export { checkStep, exportManifest, stepDispositions, stepFileName, wantsDxf, wantsStep } from './step'; export type { DxfPart, ExportPart, StepCheck } from './step'; +export { cutsFromSheet, maxCutThicknessMm, statedThicknessMm, thicknessRule, tooThickText } from './thickness'; export { drawFlatPart, dxfFileName, flatFaceDxf, needsTessellatedEdges, pickFlatFace, tessellatedPoints, withDrawnDxf } from './dxf'; export type { BodyDetails, DxfResult, FlatFace, TessellatedEdges } from './dxf'; export { bomColumns, bomIdentity, bomProperties, bomText, flagMissingSources, mergeBom } from './bom'; diff --git a/packages/shared/src/step.ts b/packages/shared/src/step.ts index 5712d33..0291237 100644 --- a/packages/shared/src/step.ts +++ b/packages/shared/src/step.ts @@ -1,11 +1,15 @@ import type { BomLine } from './index'; import { dispositionLabel, type Disposition } from './classifier'; +import { thicknessRule } from './thickness'; /** Dispositions whose parts a team makes from a solid model, so they get a STEP file. */ export const stepDispositions: Disposition[] = ['print', 'cnc', 'cut-2d', 'fabricate']; export const wantsStep = (line: BomLine) => !line.suppressed && stepDispositions.includes(line.disposition); -/** A 2D-cut part also gets a DXF of its flat face, so it can go straight to a laser, router, or waterjet. */ -export const wantsDxf = (line: Pick) => !line.suppressed && line.disposition === 'cut-2d'; +/** + * A 2D-cut part also gets a DXF of its flat face, so it can go straight to a laser, router, or waterjet, unless its + * stated thickness is over 3 mm and no person chose 2D cut: then it is printed (`thicknessRule`). + */ +export const wantsDxf = (line: Pick) => !line.suppressed && thicknessRule(line).disposition === 'cut-2d'; /** Readable, unique, filesystem-safe name: part name, then part ID, then a short configuration tag when configured. */ export function stepFileName(line: Pick, used: Set) { @@ -51,7 +55,11 @@ export type DxfPart = { fileName: string; state: 'pending' | 'ready' | 'failed'; export type ExportPart = { lineKey: string; name: string; qty: number; disposition: Disposition; partId: string | null; configuration: string | null; sourceUrl: string; fileName: string; state: 'pending' | 'translating' | 'ready' | 'failed'; error: string | null; units: string | null; nameMatches: boolean | null; bytes: number | null; /** Present only for 2D-cut parts. */ - dxf?: DxfPart }; + dxf?: DxfPart; + /** A person chose this part's process, so the thickness rule leaves it alone. */ + manual?: boolean; + /** Why the export changed how the part is made, e.g. a flat part over 3 mm thick filed as a print. */ + note?: string | null }; /** The manifest and failure report that go in every ZIP, in plain text so any team member can read them. */ export function exportManifest(input: { assemblyName: string; documentName?: string; revision?: { type: string; id: string } | null; scannedAt: string; exportedAt: string; parts: ExportPart[] }) { @@ -77,6 +85,8 @@ export function exportManifest(input: { assemblyName: string; documentName?: str ]; if (cutReady.length) lines.push('', 'DXF cut files (2D cut folder):', 'File | Part | Qty | Size (mm) | Thickness (mm) | Onshape source', ...cutReady.map(part => `${part.dxf!.fileName} | ${part.name} | ${part.qty} | ${size(part.dxf!.widthMm)} x ${size(part.dxf!.heightMm)} | ${size(part.dxf!.thicknessMm)} | ${part.sourceUrl}`)); + const noted = input.parts.filter(part => part.note); + if (noted.length) lines.push('', 'Notes:', ...noted.map(part => `${part.name}: ${part.note}`)); if (failed.length) lines.push('', 'Failed parts (no file in this ZIP):', ...failed.map(part => `${part.name} (qty ${part.qty}): ${part.error ?? 'unknown error'} | ${part.sourceUrl}`)); if (cutFailed.length) lines.push('', 'DXF not made (the STEP file is unaffected):', ...cutFailed.map(part => `${part.name} (qty ${part.qty}): ${part.dxf!.error ?? 'unknown error'} | ${part.sourceUrl}`)); return `${lines.join('\n')}\n`; diff --git a/packages/shared/src/thickness.test.ts b/packages/shared/src/thickness.test.ts new file mode 100644 index 0000000..1a25730 --- /dev/null +++ b/packages/shared/src/thickness.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { aggregateAssembly, classifyLine, cutsFromSheet, statedThicknessMm, thicknessRule, wantsDxf, type AssemblyInstance, type BomLine } from './index'; + +const context = { documentId: 'doc', workspaceOrVersion: 'w' as const, workspaceOrVersionId: 'work', elementId: 'element' }; +function line(name: string, extra: Partial = {}): BomLine { + return aggregateAssembly({ rootAssembly: { instances: [{ id: 'i', name, type: 'Part', documentId: 'doc', elementId: 'element', partId: 'part', ...extra }] } }, context, 'Assembly').lines[0]; +} +const classify = (value: BomLine, override: Parameters[2] = null) => classifyLine(value, null, override, 'doc'); +const stated = (name: string, configuration: string | null = null) => statedThicknessMm({ name, description: null, configuration })?.mm ?? null; + +describe('flat parts: 2D cut at 3 mm or less, 3D print above (owner rule, 2026-10-28)', () => { + it('cuts a plate exactly 3 mm thick, allowing float noise, and prints anything thicker', () => { + expect(cutsFromSheet(3)).toBe(true); + expect(cutsFromSheet(3.0001)).toBe(true); + expect(cutsFromSheet(3.2)).toBe(false); + expect(cutsFromSheet(3.175)).toBe(false); + const three = classify(line('3mm Side Plate')); + expect(three).toMatchObject({ disposition: 'cut-2d', dispositionSource: 'suggestion' }); + expect(three.dispositionEvidence).toContain('flat, 3 mm thick: 3 mm or less, so cut from sheet'); + expect(wantsDxf(three)).toBe(true); + }); + it('prints a 3.2 mm plate and a 1/8 in plate (3.175 mm), with the thickness as evidence', () => { + const thick = classify(line('3.2mm Side Plate')); + expect(thick).toMatchObject({ disposition: 'print', dispositionSource: 'suggestion' }); + expect(thick.dispositionEvidence).toContain('flat, 3.2 mm thick: over 3 mm, so 3D printed'); + const inch = classify(line('1/8 in Side Plate')); + expect(inch.disposition).toBe('print'); + expect(inch.dispositionEvidence).toContain('flat, 3.18 mm thick: over 3 mm, so 3D printed'); + expect(classify(line('Gusset 0.125"')).disposition).toBe('print'); + expect(classify(line('6mm Side Plate')).dispositionEvidence).toContain('flat, 6 mm thick: over 3 mm, so 3D printed'); + }); + it('reads thickness from a configuration value', () => { + expect(classify(line('Side Plate', { configuration: 'Thickness=6+mm' })).disposition).toBe('print'); + expect(classify(line('Side Plate', { configuration: 'Thickness=0.0625%20in' })).disposition).toBe('cut-2d'); + }); + it('leaves a plate with no stated thickness as 2D cut, for the export to measure', () => { + const plain = classify(line('Drivetrain side plate')); + expect(plain.disposition).toBe('cut-2d'); + expect(plain.dispositionEvidence).toContain('flat-part wording in name'); + expect(stated('Drivetrain side plate')).toBeNull(); + }); + it('keeps a person’s choice: a 6 mm plate set to 2D cut by hand still gets a DXF', () => { + const manual = classify(line('6mm Side Plate'), 'cut-2d'); + expect(manual).toMatchObject({ disposition: 'cut-2d', dispositionSource: 'manual' }); + expect(thicknessRule(manual)).toEqual({ disposition: 'cut-2d', note: null }); + expect(wantsDxf(manual)).toBe(true); + // A team tag is not a person's choice in this panel, so the export prints a thick tagged plate. + const tagged = classify(line('6mm Side Plate [laser]')); + expect(tagged).toMatchObject({ disposition: 'cut-2d', dispositionSource: 'explicit-metadata' }); + expect(thicknessRule(tagged)).toEqual({ disposition: 'print', note: 'flat, 6 mm thick: over 3 mm, so 3D printed' }); + expect(wantsDxf(tagged)).toBe(false); + }); + it('tells a thickness from a size', () => { + expect(stated('300x200x3mm plate')).toBe(3); + expect(stated('Plate 100x60mm 2mm')).toBe(2); + expect(stated('Plate, thickness: 1/4 in')).toBe(6.35); + expect(stated('Side plate t=2.5mm')).toBe(2.5); + expect(stated('Plate 5mm thick 120mm')).toBe(5); + // A long length is a size, and two short ones are ambiguous. + expect(stated('120mm plate')).toBeNull(); + expect(stated('Plate 2mm 4mm')).toBeNull(); + expect(stated('Plate', 'Thickness=1.5+mm')).toBe(1.5); + }); +}); diff --git a/packages/shared/src/thickness.ts b/packages/shared/src/thickness.ts new file mode 100644 index 0000000..1c23de4 --- /dev/null +++ b/packages/shared/src/thickness.ts @@ -0,0 +1,75 @@ +import type { BomLine } from './index'; + +/** + * The owner's rule (2026-10-28): a flat part is cut from sheet, with a DXF, only when it is 3 mm thick or less; + * anything thicker is 3D printed from its STEP. The tolerance absorbs float noise such as 3.0001 mm, but not 1/8 in + * (3.175 mm), which prints. + */ +export const maxCutThicknessMm = 3; +const toleranceMm = 0.005; +export const cutsFromSheet = (thicknessMm: number) => thicknessMm <= maxCutThicknessMm + toleranceMm; +/** "3", "3.18", never "3.000". */ +export const formatMm = (value: number) => String(Math.round(value * 100) / 100); +/** Evidence and export note for a flat part too thick to cut. */ +export const tooThickText = (thicknessMm: number) => `flat, ${formatMm(thicknessMm)} mm thick: over ${maxCutThicknessMm} mm, so 3D printed`; + +const units: Record = { mm: 1, millimeter: 1, millimetre: 1, millimeters: 1, millimetres: 1, cm: 10, in: 25.4, inch: 25.4, inches: 25.4, '"': 25.4 }; +const number = String.raw`(\d+\s+\d+\/\d+|\d+\/\d+|\d*\.\d+|\d+)`; +const unit = String.raw`(millimet(?:er|re)s?|mm|cm|inch(?:es)?|in\b|")`; +const value = (text: string) => { + const mixed = text.trim().match(/^(\d+)\s+(\d+)\/(\d+)$/); + if (mixed) return Number(mixed[1]) + Number(mixed[2]) / Number(mixed[3]); + const fraction = text.match(/^(\d+)\/(\d+)$/); + return fraction ? Number(fraction[1]) / Number(fraction[2]) : Number(text); +}; +const toMm = (amount: string, name: string) => value(amount) * (units[name.toLowerCase()] ?? NaN); +/** Every length with a unit in the text, in mm. */ +const lengths = (text: string) => [...text.matchAll(new RegExp(`${number}\\s*${unit}`, 'gi'))].map(match => toMm(match[1], match[2])).filter(mm => Number.isFinite(mm) && mm > 0); + +/** Onshape configuration pairs, `+` and percent-escapes undone ("Thickness=3+mm" is 3 mm). */ +function configurationPairs(configuration: string | null) { + if (!configuration || configuration === 'default') return []; + return configuration.split(';').map(pair => { + const [key, raw = ''] = pair.split('='); + let text = raw.replace(/\+/g, ' '); + try { text = decodeURIComponent(text); } catch { /* keep it as written */ } + return { key: key.replace(/_/g, ' '), value: text }; + }); +} + +/** + * A part's thickness when its name, description, or configuration states it: a thickness configuration value, a + * phrase like "6mm thick" or "thickness 1/8 in", the last of "300x200x3mm", or else the one length of an inch or + * less in the text ("3mm plate", "1/8 in Side Plate"). Longer lengths are sizes, not thickness, and two different + * short lengths are ambiguous, so neither gives a thickness. + */ +export function statedThicknessMm(line: Pick): { mm: number; from: 'name' | 'configuration' } | null { + const pairs = configurationPairs(line.configuration); + for (const pair of pairs) { + if (!/thick|\bthk\b|^\s*t\s*$|gauge|sheet/i.test(pair.key)) continue; + const found = lengths(pair.value); + if (found.length === 1) return { mm: found[0], from: 'configuration' }; + } + const text = `${line.name} ${line.description ?? ''}`; + const explicit = text.match(new RegExp(`${number}\\s*${unit}\\s*thick`, 'i')) ?? text.match(new RegExp(`thick(?:ness)?\\s*[:=]?\\s*${number}\\s*${unit}`, 'i')) + ?? text.match(new RegExp(`\\bt\\s*=\\s*${number}\\s*${unit}`, 'i')); + if (explicit) { const mm = toMm(explicit[1], explicit[2]); if (Number.isFinite(mm) && mm > 0) return { mm, from: 'name' }; } + const triple = text.match(new RegExp(String.raw`(\d*\.?\d+)\s*[x×]\s*(\d*\.?\d+)\s*[x×]\s*(\d*\.?\d+)\s*${unit}`, 'i')); + if (triple) { const mm = Math.min(...[triple[1], triple[2], triple[3]].map(item => toMm(item, triple[4]))); if (Number.isFinite(mm) && mm > 0) return { mm, from: 'name' }; } + // Sizes written as "100x60mm" are not thickness: drop them before looking for a lone short length. + const loose = text.replace(new RegExp(String.raw`(\d*\.?\d+)\s*[x×]\s*(\d*\.?\d+)\s*${unit}`, 'gi'), ' '); + const configured = pairs.map(pair => pair.value).join(' '); + const short = [...new Set([...lengths(loose), ...lengths(configured)].filter(mm => mm <= 25.4 + toleranceMm).map(mm => Math.round(mm * 1000) / 1000))]; + if (short.length === 1) return { mm: short[0], from: lengths(loose).some(mm => Math.abs(mm - short[0]) < 0.001) ? 'name' : 'configuration' }; + return null; +} + +/** + * How an exported part is made after the thickness rule: a 2D-cut part (not chosen by a person) whose stated + * thickness is over 3 mm is printed instead, with the reason as its note. + */ +export function thicknessRule(line: Pick): { disposition: BomLine['disposition']; note: string | null } { + if (line.disposition !== 'cut-2d' || line.dispositionSource === 'manual') return { disposition: line.disposition, note: null }; + const stated = statedThicknessMm(line); + return stated && !cutsFromSheet(stated.mm) ? { disposition: 'print', note: tooThickText(stated.mm) } : { disposition: line.disposition, note: null }; +} From fca9002177b279f72526af046fe700a9aded3219 Mon Sep 17 00:00:00 2001 From: Sushil Muthuvelkumar <123318620+sushilm20@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:55:58 -0400 Subject: [PATCH 3/3] Greptile: only thickness-keyed configuration values and plain lengths are thickness; workbook DXF names say when the export's measurement decides --- CLAUDE.md | 4 ++-- packages/excel/src/workbook.test.ts | 8 +++++++- packages/excel/src/workbook.ts | 6 ++++-- packages/shared/src/thickness.test.ts | 5 +++++ packages/shared/src/thickness.ts | 22 +++++++++++++--------- 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4cab3c7..0a78502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,9 +283,9 @@ DXF for flat parts (2026-10-28, owner request "do dxf for parts it detects as a - **Cost.** One billable request per 2D-cut part (two with a spline or ellipse anywhere on it), on top of its STEP's start, polls, and download. Counted in the ledger as `body-details` and `tessellated-edges` under purpose `export`, inside the same per-poll budget (`pollBudget` 12). Geometry reads come after STEP starts and polls in each poll, so a rate limit on them never costs a STEP its deadline, and one starts only with at least two requests left. No translation, so no polling. - **Face.** `pickFlatFace` (`packages/shared/src/dxf.ts`) takes the solid body (the one whose id is the part id when there are several) and its largest planar face with an outer loop; a countersink shrinks its own side, so the through profile wins. `flatFaceDxf` draws it seen from outside the part (outward normal toward the viewer), x along the longest straight outer edge, origin at the lower-left corner, in mm: LINE, exact ARC (direction from the edge's `midPoint`, else the curve normal), CIRCLE for closed circular edges, and tessellated segments for anything else. R12 ASCII (`AC1009`) with `$INSUNITS` 4 (mm), everything on layer `CUT`, no TABLES section. Thickness is measured along the normal over the body's points, for the manifest. - **Where it runs.** Parsing and drawing a 400-hole plate took 10–26 ms in Node, over the free plan's 10 ms with several plates per poll, so the Worker only fetches and stores Onshape's JSON (`export-file:{userId}:{jobId}:{index}:body` and `:edges`, 1-hour TTL; a text search, no parse) and serves it at `GET /api/exports/{jobId}/files/{index}.body|.edges`. The browser draws with `drawFlatPart` and fills sizes with `withDrawnDxf` before building the ZIP, as matching moved to the browser in PR 7f. -- **Job and files.** `ExportPart.dxf` (`DxfPart`: fileName, state, error, bytes, sizes, `edges`) exists only for `wantsDxf` lines (active cut-2d). A job is done only when no DXF is pending. A DXF failure is the DXF's alone: the STEP carries on, the app lists it as "{part} (DXF)", and MANIFEST.txt has a "DXF not made (the STEP file is unaffected)" section. The DXF sits beside the STEP in `2D_cut/` as `dxfFileName(stepFileName)`; a ZIP with only DXFs is still made. The manifest lists each DXF with its size and thickness. The workbook's Make sheet gains a "DXF file" column after "STEP file" (9 columns). `pnpm sample:step` now sets the fixture wheel to 2D cut, so the committed sample carries a DXF. +- **Job and files.** `ExportPart.dxf` (`DxfPart`: fileName, state, error, bytes, sizes, `edges`) exists only for `wantsDxf` lines (active cut-2d). A job is done only when no DXF is pending. A DXF failure is the DXF's alone: the STEP carries on, the app lists it as "{part} (DXF)", and MANIFEST.txt has a "DXF not made (the STEP file is unaffected)" section. The DXF sits beside the STEP in `2D_cut/` as `dxfFileName(stepFileName)`; a ZIP with only DXFs is still made. The manifest lists each DXF with its size and thickness. The workbook's Make sheet gains a "DXF file" column after "STEP file" (9 columns); for an automatic plate with no stated thickness it reads "{name}.dxf (if 3 mm thick or less; otherwise 3D printed)", since only the export measures it. `pnpm sample:step` now sets the fixture wheel to 2D cut, so the committed sample carries a DXF. - **Through check.** A cut file must be a plate's outside with every loop running through. `runsThrough` rejects a largest face with body points beyond it (a pocket floor), a part with no opposite-facing flat face at its far extreme, and any loop whose walls do not connect, face to face through the edge-sharing in the loops, to that far face (a blind recess, which would otherwise be cut as a hole). A recess with a through hole in its floor still passes; the manifest says to check the size before cutting. -- **Thickness rule (owner, 2026-10-28: "it only does the dxf if the panel is like 3mm or less, otherwise it should be 3dp step file").** A flat part is cut from sheet with a DXF only at 3 mm or less (`cutsFromSheet`, 0.005 mm tolerance for float noise; 1/8 in = 3.175 mm prints). `statedThicknessMm` (`packages/shared/src/thickness.ts`) reads a stated thickness: a configuration value whose key names thickness (`Thickness=3+mm`, URL-decoded), "6mm thick" / "thickness: 1/4 in" / "t=2.5mm", the smallest of "300x200x3mm", or else the one length of 25.4 mm or less in the name, description, and configuration (inches and fractions converted; "100x60mm" sizes ignored; two different short lengths are ambiguous). `decideDisposition` then makes a flat plate over 3 mm `print` with evidence "flat, 6 mm thick: over 3 mm, so 3D printed" (or "… 3 mm or less, so cut from sheet"). The export applies the same rule (`thicknessRule`) to a cut-2d part not chosen by a person, e.g. one set by a team tag: it is filed as a print with that `note` and no DXF. When nothing states the thickness, the export measures it from the body details it already reads (no extra request): `withDrawnDxf` turns a part over 3 mm into a print, moves its STEP to `3D_print/`, drops the DXF, and adds the note. Notes appear in the Make panel (an "Export notes" list) and in MANIFEST.txt under "Notes:". A person's manual choice always wins (`ExportPart.manual`). +- **Thickness rule (owner, 2026-10-28: "it only does the dxf if the panel is like 3mm or less, otherwise it should be 3dp step file").** A flat part is cut from sheet with a DXF only at 3 mm or less (`cutsFromSheet`, 0.005 mm tolerance for float noise; 1/8 in = 3.175 mm prints). `statedThicknessMm` (`packages/shared/src/thickness.ts`) reads a stated thickness: a configuration value whose key names thickness (`Thickness=3+mm`, URL-decoded), "6mm thick" / "thickness: 1/4 in" / "t=2.5mm", the smallest of "300x200x3mm", or else the one length of 25.4 mm or less in the name or description (inches and fractions converted; "100x60mm" sizes, lengths named as holes, spacing, bores and the like, and configuration values under other keys ignored; two different short lengths are ambiguous). `decideDisposition` then makes a flat plate over 3 mm `print` with evidence "flat, 6 mm thick: over 3 mm, so 3D printed" (or "… 3 mm or less, so cut from sheet"). The export applies the same rule (`thicknessRule`) to a cut-2d part not chosen by a person, e.g. one set by a team tag: it is filed as a print with that `note` and no DXF. When nothing states the thickness, the export measures it from the body details it already reads (no extra request): `withDrawnDxf` turns a part over 3 mm into a print, moves its STEP to `3D_print/`, drops the DXF, and adds the note. Notes appear in the Make panel (an "Export notes" list) and in MANIFEST.txt under "Notes:". A person's manual choice always wins (`ExportPart.manual`). - **Mock.** `mockPlateBody` in `mock-onshape.ts` is a 100 × 60 mm plate, 3 mm thick unless given (one fillet arc, a 10 mm hole, side walls, optional spline) shaped like `BTExportModelBodiesResponse`; vectors are read as `{x, y, z}` or arrays. - **Not verified live** (no Onshape request was made): the real body details JSON shape (vector form, `midPoint` on arcs, `isOrientedWithFace`, `area` units), whether linked-document parts need `linkDocumentId`, the response size and Worker time for a large plate, and that the DXF opens at the right size in LightBurn, Inkscape, or a router's CAM. diff --git a/packages/excel/src/workbook.test.ts b/packages/excel/src/workbook.test.ts index 26ef52d..7869393 100644 --- a/packages/excel/src/workbook.test.ts +++ b/packages/excel/src/workbook.test.ts @@ -127,7 +127,13 @@ describe('Excel export', () => { const bracket = plated.lines.find(line => line.partId === 'bracket')!; const cut = classifyScan(plated, { teamNumber: null }, { [bracket.bomLineKey]: 'cut-2d' }); const make = text(unzipSync(buildExcelWorkbook(cut, new Date('2026-10-24T12:00:00Z')).bytes), 3); - expect(row(make, 'Mystery Bracket')).toMatch(/Mystery_Bracket__bracket\.step.*Mystery_Bracket__bracket\.dxf/); + // Set by hand, so no thickness check applies. + expect(row(make, 'Mystery Bracket')).toMatch(/Mystery_Bracket__bracket\.step.*Mystery_Bracket__bracket\.dxf line.partId === 'bracket')!.name = 'Side Plate'; + const measured = text(unzipSync(buildExcelWorkbook(classifyScan(auto, { teamNumber: null }, {}), new Date('2026-10-24T12:00:00Z')).bytes), 3); + expect(row(measured, 'Side Plate')).toContain('Side_Plate__bracket.dxf (if 3 mm thick or less; otherwise 3D printed)'); expect(row(make, 'Intake Guide')).not.toContain('.dxf'); }); diff --git a/packages/excel/src/workbook.ts b/packages/excel/src/workbook.ts index 11d0fdc..ae56ac0 100644 --- a/packages/excel/src/workbook.ts +++ b/packages/excel/src/workbook.ts @@ -1,5 +1,5 @@ import { strToU8, zipSync } from 'fflate'; -import { dxfFileName, isBundle, metadataIssueText, stepFileName, wantsDxf, wantsStep, type BomLine, type CatalogHealth, type Disposition, type ScanResult, type VendorHealth, type VendorRecord } from '../../shared/src/index'; +import { dxfFileName, isBundle, maxCutThicknessMm, metadataIssueText, statedThicknessMm, stepFileName, wantsDxf, wantsStep, type BomLine, type CatalogHealth, type Disposition, type ScanResult, type VendorHealth, type VendorRecord } from '../../shared/src/index'; type Cell = { col: number; type: 's' | 'n' | 'f'; value: string | number; style: number; cached?: number }; type Sheet = { name: string; rows: Map; widths: number[]; fixed: Record; merges: string[]; links: { cell: string; url: string }[]; filter?: string; frozen?: number; landscape?: boolean; drawing?: boolean; tall?: number[] }; @@ -203,7 +203,9 @@ function makeSheet(result: ScanResult, now: Date) { put(target, row, 5, line.material ?? '', body); const file = files.get(line.bomLineKey) ?? ''; put(target, row, 6, file, body); - put(target, row, 7, file && wantsDxf(line) ? dxfFileName(file) : '', body); + // Without a stated thickness the export measures the part, and one over 3 mm is printed instead, so the name is conditional. + const dxf = file && wantsDxf(line) ? dxfFileName(file) : ''; + put(target, row, 7, dxf && line.dispositionSource !== 'manual' && !statedThicknessMm(line) ? `${dxf} (if ${maxCutThicknessMm} mm thick or less; otherwise 3D printed)` : dxf, body); put(target, row, 8, 'Open Part Studio', band(index, S.link, S.linkAlt), 's', line.partStudioUrl); put(target, row, 9, line.dispositionEvidence.join('; '), band(index, S.wrap, S.wrapAlt)); }); diff --git a/packages/shared/src/thickness.test.ts b/packages/shared/src/thickness.test.ts index 1a25730..4989ff9 100644 --- a/packages/shared/src/thickness.test.ts +++ b/packages/shared/src/thickness.test.ts @@ -60,5 +60,10 @@ describe('flat parts: 2D cut at 3 mm or less, 3D print above (owner rule, 2026-1 expect(stated('120mm plate')).toBeNull(); expect(stated('Plate 2mm 4mm')).toBeNull(); expect(stated('Plate', 'Thickness=1.5+mm')).toBe(1.5); + // Other dimensions are not thickness, whether configured or named. + expect(stated('Side Plate', 'Hole_Spacing=8+mm')).toBeNull(); + expect(classify(line('Side Plate', { configuration: 'Hole_Spacing=8+mm' })).disposition).toBe('cut-2d'); + expect(stated('Side Plate 8mm hole spacing')).toBeNull(); + expect(stated('Side Plate bore 5mm 2mm')).toBe(2); }); }); diff --git a/packages/shared/src/thickness.ts b/packages/shared/src/thickness.ts index 1c23de4..b0f6642 100644 --- a/packages/shared/src/thickness.ts +++ b/packages/shared/src/thickness.ts @@ -39,9 +39,10 @@ function configurationPairs(configuration: string | null) { /** * A part's thickness when its name, description, or configuration states it: a thickness configuration value, a - * phrase like "6mm thick" or "thickness 1/8 in", the last of "300x200x3mm", or else the one length of an inch or - * less in the text ("3mm plate", "1/8 in Side Plate"). Longer lengths are sizes, not thickness, and two different - * short lengths are ambiguous, so neither gives a thickness. + * phrase like "6mm thick" or "thickness 1/8 in", the smallest of "300x200x3mm", or else the one length of an inch or + * less in the name or description ("3mm plate", "1/8 in Side Plate"). Longer lengths are sizes, lengths named as + * holes, spacing, and the like are other dimensions, and two different short lengths are ambiguous, so none of them + * gives a thickness. */ export function statedThicknessMm(line: Pick): { mm: number; from: 'name' | 'configuration' } | null { const pairs = configurationPairs(line.configuration); @@ -56,12 +57,15 @@ export function statedThicknessMm(line: Pick 0) return { mm, from: 'name' }; } const triple = text.match(new RegExp(String.raw`(\d*\.?\d+)\s*[x×]\s*(\d*\.?\d+)\s*[x×]\s*(\d*\.?\d+)\s*${unit}`, 'i')); if (triple) { const mm = Math.min(...[triple[1], triple[2], triple[3]].map(item => toMm(item, triple[4]))); if (Number.isFinite(mm) && mm > 0) return { mm, from: 'name' }; } - // Sizes written as "100x60mm" are not thickness: drop them before looking for a lone short length. - const loose = text.replace(new RegExp(String.raw`(\d*\.?\d+)\s*[x×]\s*(\d*\.?\d+)\s*${unit}`, 'gi'), ' '); - const configured = pairs.map(pair => pair.value).join(' '); - const short = [...new Set([...lengths(loose), ...lengths(configured)].filter(mm => mm <= 25.4 + toleranceMm).map(mm => Math.round(mm * 1000) / 1000))]; - if (short.length === 1) return { mm: short[0], from: lengths(loose).some(mm => Math.abs(mm - short[0]) < 0.001) ? 'name' : 'configuration' }; - return null; + // Sizes written as "100x60mm", and lengths named as another dimension ("8mm hole spacing", "bore 5mm"), are + // not thickness: drop them before looking for a lone short length. Configuration values count only under a + // thickness key (above); any other ("Hole_Spacing=8+mm") is some other dimension. Unknown is left to the geometry. + const other = String.raw`hole|holes|bore|spacing|pitch|radius|fillet|chamfer|slot|dia(?:meter)?|grid|pattern|offset|bolt|screw|wide|width|long|length|tall|height`; + const loose = text.replace(new RegExp(String.raw`(\d*\.?\d+)\s*[x×]\s*(\d*\.?\d+)\s*${unit}`, 'gi'), ' ') + .replace(new RegExp(`${number}\\s*${unit}\\s*(?:${other})\\b`, 'gi'), ' ') + .replace(new RegExp(`\\b(?:${other})\\s*[:=]?\\s*${number}\\s*${unit}`, 'gi'), ' '); + const short = [...new Set(lengths(loose).filter(mm => mm <= 25.4 + toleranceMm).map(mm => Math.round(mm * 1000) / 1000))]; + return short.length === 1 ? { mm: short[0], from: 'name' } : null; } /**