Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions apps/web/fixture-api.ts
Original file line number Diff line number Diff line change
@@ -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]] : []));
Expand Down Expand Up @@ -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());
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/Landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const features: Array<{ icon: ReactNode; title: string; text: string; image: str
{ icon: <ShoppingCart aria-hidden />, 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: <Wrench aria-hidden />, 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: <FileSearch aria-hidden />, 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.' },
];
Expand Down Expand Up @@ -60,7 +60,7 @@ export function Landing() {
<ul className="landing-list">
<li>Control Kit only reads documents. It never edits your CAD.</li>
<li>Onshape sign-in tokens are stored encrypted on our server; your browser holds only an opaque session cookie.</li>
<li>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.</li>
<li>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.</li>
<li>Vendor prices come from the vendors' public catalogs. Totals are list prices before shipping, tax, or team discounts.</li>
<li>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.</li>
</ul>
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/app/Make.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ export function Make({ kit, open, go }: { kit: ControlKit; open: (key: string) =

<section className="panel" aria-labelledby="step-export">
<div className="panel-head">
<div><h2 id="step-export">STEP files</h2><p>For slicers and CAM. A 2D-cut part still needs a cut file (DXF) made from its flat face.</p></div>
<div><h2 id="step-export">STEP files</h2><p>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.</p></div>
<Button loading={step?.running} onClick={() => void kit.downloadStep()}><Download aria-hidden size={15} /> Download STEP files</Button>
</div>
{step?.running && <div className="panel-body stack" aria-live="polite">
<Progress value={step.ready + step.failed} max={Math.max(step.total, 1)} label="STEP export progress" showValue />
<p className="muted small">{step.total ? `${step.ready} of ${step.total} exported${step.failed ? `, ${step.failed} failed` : ''}. Onshape translates each part; large parts take longer.` : 'Starting the export…'}</p>
</div>}
{step && !step.running && step.zipMade !== null && <div className="panel-body stack">
{step.zipMade ? <Alert tone={step.failures.length ? 'warning' : 'success'} title={`${step.ready} of ${step.total} STEP files downloaded`}>{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.'}</Alert>
{step.zipMade ? <Alert tone={step.failures.length ? 'warning' : 'success'} title={`${step.ready} of ${step.total} STEP files${step.dxfTotal ? ` and ${step.dxfReady ?? 0} of ${step.dxfTotal} DXF cut files` : ''} downloaded`}>{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.'}</Alert>
: <Alert tone="danger" title="No STEP file could be exported">No ZIP was made. Each part and its reason is listed below.</Alert>}
{(step.notes?.length ?? 0) > 0 && <div className="rows" aria-label="Export notes">{step.notes!.map(note => <div key={`${note.name}:${note.reason}`} className="row">
<div className="row-main"><span className="row-title"><span>{note.name}</span></span><span className="muted small">{note.reason}</span></div>
<div className="row-aside"><a className="link small" href={note.url} target="_blank" rel="noopener noreferrer">Open in Onshape <ExternalLink aria-hidden size={11} /></a></div>
</div>)}</div>}
{step.failures.length > 0 && <div className="rows">{step.failures.map(failure => <div key={`${failure.name}:${failure.reason}`} className="row">
<div className="row-main"><span className="row-title"><span>{failure.name}</span></span><span className="muted small">{failure.reason}</span></div>
<div className="row-aside"><a className="link small" href={failure.url} target="_blank" rel="noopener noreferrer">Open in Onshape <ExternalLink aria-hidden size={11} /></a></div>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/Policies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ function Privacy() {
<h2>What Control Kit reads</h2>
<ul className="landing-list">
<li><strong>Your Onshape profile</strong> (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.</li>
<li><strong>Your documents</strong> (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.</li>
<li><strong>Your documents</strong> (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.</li>
</ul>
</section>
<section className="landing-section">
<h2>What it keeps, and for how long</h2>
<ul className="landing-list">
<li>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.</li>
<li>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.</li>
<li>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.</li>
<li>Your team number, if you enter one in Settings.</li>
<li>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.</li>
<li>AI review's suggestions for 30 days, so asking again about the same parts gives the same answer without another AI call.</li>
Expand Down
Loading
Loading