= 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');
});