diff --git a/CLAUDE.md b/CLAUDE.md index a93cbc9..0a78502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,6 +278,17 @@ 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). 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); 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 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. + Matching fixes (2026-10-28, owner reports from the HORS panel: a Control Hub that needed confirming, a REV servo and a goBILDA bearing with no match): - **Discontinued SKUs.** `successorOf` (`known-products.ts`) lists SKUs a vendor no longer sells with what it sells instead, checked against REV's feed of 2026-10-28: REV-31-1152 (Control Hub) is REV-31-1595; REV-41-1097 (Smart Robot Servo) is the V2, Balanced REV-41-3334 or UltraSpeed REV-41-3336. In `matchCatalog`, a part named or numbered with an old SKU becomes an exact match to a single successor ("REV-31-1152 is discontinued; REV Control Hub is sold as REV-31-1595") or a possible match with the successors as choices. The old SKU itself is never listed or priced. - **Sizes and bores.** `spellSizes` turns "8x14x5mm" (or "8 × 14 × 5 mm") into "8mm 14mm 5mm" for library names and the tagged search; a configuration's own size field is ignored when the name gives sizes. `boreConflict` drops products with the other bore: "Round Bore" excludes goBILDA's REX/hex bore, and "REX" or "hex" excludes round ones. 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 1eb1487..367b012 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..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. 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 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 &&
@@ -29,8 +29,12 @@ 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.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/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..4540be0 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,11 @@ 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; + /** 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 = | { type: 'progress'; stage: string; step?: ScanStep; current: number; total: number; instances: number; counts?: ScanCounts } | { type: 'rate-limit'; retryInMs: number } @@ -541,7 +545,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 +556,50 @@ 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; + 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 }); + 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..f4c5b20 --- /dev/null +++ b/apps/worker/src/dxf.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +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. */ +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('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/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..cc6c3a4 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,38 @@ ENDSEC; END-ISO-10303-21; `; +/** + * 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, 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; + 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, 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, 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, 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. */ +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 +154,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..30927d1 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,94 @@ 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('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' }] } }, + { 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..173c8c3 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, thicknessRule, 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, 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}`; +/** 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,13 @@ 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; + // 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, @@ -68,12 +78,23 @@ 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) 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]; 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 +137,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..7b9061c 100644 --- a/packages/excel/src/step-zip.test.ts +++ b/packages/excel/src/step-zip.test.ts @@ -42,8 +42,37 @@ 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 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'); }); 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..7869393 100644 --- a/packages/excel/src/workbook.test.ts +++ b/packages/excel/src/workbook.test.ts @@ -122,6 +122,21 @@ 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); + // 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'); + }); + 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..ae56ac0 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, 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[] }; @@ -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,13 @@ 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); + // 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)); }); 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 +337,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 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. */ +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. 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 { + 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 23ac00c..b545c58 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -14,8 +14,11 @@ 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 { 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'; 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..0291237 100644 --- a/packages/shared/src/step.ts +++ b/packages/shared/src/step.ts @@ -1,9 +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, 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) { @@ -41,25 +47,47 @@ 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; + /** 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[] }) { 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}`)); + 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..4989ff9 --- /dev/null +++ b/packages/shared/src/thickness.test.ts @@ -0,0 +1,69 @@ +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); + // 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 new file mode 100644 index 0000000..b0f6642 --- /dev/null +++ b/packages/shared/src/thickness.ts @@ -0,0 +1,79 @@ +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 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); + 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", 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; +} + +/** + * 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 }; +} diff --git a/samples/FTC_Competition_Robot_STEP.zip b/samples/FTC_Competition_Robot_STEP.zip index 6564ed1..4c2dd26 100644 Binary files a/samples/FTC_Competition_Robot_STEP.zip and b/samples/FTC_Competition_Robot_STEP.zip differ diff --git a/scripts/generate-sample-step.ts b/scripts/generate-sample-step.ts index fb25d99..f4bc709 100644 --- a/scripts/generate-sample-step.ts +++ b/scripts/generate-sample-step.ts @@ -1,11 +1,12 @@ import { readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { aggregateAssembly, classifyScan, mergeBom, type AssemblyDefinition } from '../packages/shared/src/index'; +import { aggregateAssembly, classifyScan, drawFlatPart, mergeBom, withDrawnDxf, type AssemblyDefinition } from '../packages/shared/src/index'; import { buildStepZip } from '../packages/excel/src/step-zip'; import { createMockOnshape, mockBom } from '../apps/worker/src/mock-onshape'; -import { advanceExport, budgeted, createExportJob, fileKey } from '../apps/worker/src/step-export'; +import { advanceExport, budgeted, createExportJob, dxfKey, fileKey } from '../apps/worker/src/step-export'; -// Runs the real export job against mock Onshape: the intake guide succeeds, the motor fails deliberately. +// Runs the real export job against mock Onshape: the intake guide succeeds, the motor fails deliberately, and the +// wheel, set to 2D cut here, gets its STEP and a DXF of the mock plate's flat face. const root = process.cwd(); const fixture = JSON.parse(await readFile(resolve(root, 'fixtures/assembly-ftc-robot.json'), 'utf8')) as AssemblyDefinition; const result = aggregateAssembly(fixture, { documentId: 'robot', workspaceOrVersion: 'w', workspaceOrVersionId: 'work1', elementId: 'assembly' }, 'FTC Competition Robot'); @@ -14,17 +15,27 @@ result.scannedAt = '2026-10-23T12:00:00.000Z'; result.scanId = '00000000-0000-4000-8000-000000000000'; result.diagnostics = { onshapeRequests: null, metadataIssues: [], revision: { type: 'm', id: 'fixture-microversion' } }; const motor = result.lines.find(line => 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'); });