The raw dimension trap: why storefront inputs break the factory floor
Most custom product configurators built on Shopify treat customer measurements as display attributes. When a shopper adjusts interactive sliders for bespoke window dressings, architectural acoustic panels, or custom timber shelving, theme JavaScript serialises the values into standard cart line item properties: `properties[Width] = 280cm` and `properties[Drop] = 240cm`.
While sufficient for customer email receipts, this naive representation fails across four fundamental operational axes:
- Missing Fabrication Allowances: A finished 240cm curtain drop cannot be cut at 240cm. It requires a 16cm top buckram heading allowance and a 20cm double-fold weighted bottom hem. Cutting raw fabric to finished dimensions ruins expensive yardage immediately.
- Roll Width Constraints: Finished textile products are rarely cut from infinite sheets. Drapery fabrics arrive on standardized rolls (typically 137cm to 140cm wide). Covering a 280cm window at 2.0x fullness demands 568cm of flat fabric, requiring multiple vertical drops joined by serged seams.
- Pattern Misalignment: Patterned textiles feature repeated motifs (e.g. every 64cm). If multiple drops are cut without matching the pattern phase, the horizontal motifs will stagger jarringly across vertical panel seams.
- Hardware Disconnect: Storefront orders capture finished track lengths but fail to calculate constituent hardware requirements: glider hooks, end stops, wall brackets, and draw wands.
The geometry of yield: roll widths, seam joins, and drop slicing
Deriving an accurate cut list starts with panel drop geometry. The algorithm must calculate how many vertical strips ('drops') of fabric are required to achieve the desired aesthetic fullness when the curtain is fully drawn.
First, calculate the total flat fabric width required by multiplying finished window width by the fullness ratio (typically 2.0x for modern wave fold or deep pinch pleats) and adding double-folded side hem allowances on both outer edges (typically 4cm each, adding 8cm total).
Second, determine usable width per fabric roll drop by subtracting vertical seam join allowances (2cm per side) from the fabric bolt width. Dividing total flat width by usable width and rounding up to the nearest integer gives the discrete drop count required for the project.
Pattern repeat alignment: calculating vertical match points and scrap waste
For solid or unpatterned fabrics, the raw cut length per drop is simply the finished drop plus heading and bottom hem allowances. But when working with jacquards, damasks, or printed botanical linens, the vertical pattern repeat dictates the cut schedule.
If a linen fabric has a vertical repeat of 64cm, every panel drop must begin at the identical pattern point so that when panels are stitched together side-by-side, the pattern runs continuously across the room without vertical steps.
To align repeats, the cut length per drop must round up to the nearest whole multiple of the pattern repeat. This pattern match adjustment creates predictable scrap per drop. In our made-to-measure implementations, pattern scrap is calculated dynamically and incorporated into both the commercial price and the operational cut-sheet.
import assert from "node:assert/strict";
export interface MaterialRollSpec {
rollWidthCm: number;
verticalPatternRepeatCm: number; // 0 for solid/plain fabrics
topHeadingAllowanceCm: number; // Buckram / header fold allowance
bottomHemAllowanceCm: number; // Double-fold weighted hem allowance
sideHemAllowanceCm: number; // Side seam fold per panel
seamJoinAllowanceCm: number; // Overlap per vertical join
kerfLossMm?: number; // Blade cutting width for rigid materials (wood/metal)
}
export interface ConfiguredProductInput {
productId: string;
sku: string;
finishedWidthCm: number;
finishedDropCm: number;
fullnessRatio: number; // e.g. 1.5, 2.0 (Wave fold), 2.5 (French pinch)
materialSpec: MaterialRollSpec;
hardwareConfig?: {
trackLengthCm: number;
gliderSpacingCm: number; // e.g. 1 glider every 8cm
bracketSpacingCm: number; // e.g. 1 bracket every 60cm
};
}
export interface PanelCutItem {
panelIndex: number;
cutLengthCm: number;
cutWidthCm: number;
patternMatchOffsetCm: number;
wasteLengthCm: number;
}
export interface BillOfMaterialsOutput {
status: "VALID" | "INVALID_DIMENSIONS";
errorMessage?: string;
totalFlatWidthRequiredCm: number;
dropsCount: number;
cutLengthPerDropCm: number;
totalLinearMetersRequired: number;
patternRepeatWasteMeters: number;
totalMaterialYieldPercentage: number;
cutList: PanelCutItem[];
hardwareSummary: {
gliderCount: number;
bracketCount: number;
endCapCount: number;
};
workshopAttributes: Record<string, string>;
}
export function generateManufacturingCutSheet(input: ConfiguredProductInput): BillOfMaterialsOutput {
// Defensive validation against physically impossible or non-positive dimensions
if (
input.finishedWidthCm <= 0 ||
input.finishedDropCm <= 0 ||
input.fullnessRatio < 1.0 ||
input.materialSpec.rollWidthCm <= 0
) {
return {
status: "INVALID_DIMENSIONS",
errorMessage: "Dimensions and material roll width must be strictly positive with fullness >= 1.0",
totalFlatWidthRequiredCm: 0,
dropsCount: 0,
cutLengthPerDropCm: 0,
totalLinearMetersRequired: 0,
patternRepeatWasteMeters: 0,
totalMaterialYieldPercentage: 0,
cutList: [],
hardwareSummary: { gliderCount: 0, bracketCount: 0, endCapCount: 0 },
workshopAttributes: {},
};
}
const { finishedWidthCm, finishedDropCm, fullnessRatio, materialSpec, hardwareConfig } = input;
// 1. Calculate total flat finished fabric width required to achieve target fullness
const rawFlatWidth = finishedWidthCm * fullnessRatio;
const totalFlatWidthRequiredCm = rawFlatWidth + materialSpec.sideHemAllowanceCm * 2;
// Usable width per drop accounting for joining seams
const usableWidthPerDrop = Math.max(1, materialSpec.rollWidthCm - materialSpec.seamJoinAllowanceCm * 2);
// Number of vertical fabric drops (panels) required
const dropsCount = Math.ceil(totalFlatWidthRequiredCm / usableWidthPerDrop);
// 2. Base cut length per drop accounting for heading and bottom hems
const baseCutLengthCm =
finishedDropCm + materialSpec.topHeadingAllowanceCm + materialSpec.bottomHemAllowanceCm;
// 3. Pattern repeat calculations
let cutLengthPerDropCm = baseCutLengthCm;
let patternWastePerDropCm = 0;
if (materialSpec.verticalPatternRepeatCm > 0) {
const repeatsPerDrop = Math.ceil(baseCutLengthCm / materialSpec.verticalPatternRepeatCm);
const patternAdjustedLength = repeatsPerDrop * materialSpec.verticalPatternRepeatCm;
patternWastePerDropCm = patternAdjustedLength - baseCutLengthCm;
cutLengthPerDropCm = patternAdjustedLength;
}
// 4. Generate discrete cut list items
const cutList: PanelCutItem[] = [];
for (let i = 1; i <= dropsCount; i++) {
cutList.push({
panelIndex: i,
cutLengthCm: Math.round(cutLengthPerDropCm * 100) / 100,
cutWidthCm: materialSpec.rollWidthCm,
patternMatchOffsetCm:
materialSpec.verticalPatternRepeatCm > 0
? ((i - 1) * materialSpec.verticalPatternRepeatCm) % materialSpec.verticalPatternRepeatCm
: 0,
wasteLengthCm: Math.round(patternWastePerDropCm * 100) / 100,
});
}
// Total linear fabric meters required (in metres, rounded to 2 decimals)
const totalLinearMetersRequired = Math.round((dropsCount * cutLengthPerDropCm) / 100 * 100) / 100;
const patternRepeatWasteMeters = Math.round((dropsCount * patternWastePerDropCm) / 100 * 100) / 100;
// Material yield percentage (theoretical square meters utilized vs raw square meters purchased)
const theoreticalFinishedArea = (finishedWidthCm * finishedDropCm) / 10000;
const rawFabricArea = (totalLinearMetersRequired * (materialSpec.rollWidthCm / 100));
const totalMaterialYieldPercentage =
rawFabricArea > 0 ? Math.round((theoreticalFinishedArea / rawFabricArea) * 10000) / 100 : 0;
// 5. Derive hardware counts
let gliderCount = 0;
let bracketCount = 0;
const endCapCount = hardwareConfig ? 2 : 0;
if (hardwareConfig) {
const trackWidth = hardwareConfig.trackLengthCm > 0 ? hardwareConfig.trackLengthCm : finishedWidthCm;
if (hardwareConfig.gliderSpacingCm > 0) {
gliderCount = Math.ceil(trackWidth / hardwareConfig.gliderSpacingCm) + 2;
// Wave fold tracks require even number of gliders for bilateral symmetry
if (fullnessRatio >= 2.0 && gliderCount % 2 !== 0) {
gliderCount += 1;
}
}
if (hardwareConfig.bracketSpacingCm > 0) {
bracketCount = Math.max(2, Math.ceil(trackWidth / hardwareConfig.bracketSpacingCm) + 1);
}
}
// 6. Workshop cut-sheet attributes serialised for order line properties
const workshopAttributes: Record<string, string> = {
_cut_sheet_drops: dropsCount.toString(),
_cut_sheet_length_cm: cutLengthPerDropCm.toFixed(1),
_cut_sheet_total_linear_m: totalLinearMetersRequired.toFixed(2),
_cut_sheet_pattern_repeat_cm: materialSpec.verticalPatternRepeatCm.toString(),
_cut_sheet_pattern_waste_m: patternRepeatWasteMeters.toFixed(2),
_cut_sheet_glider_count: gliderCount.toString(),
_cut_sheet_bracket_count: bracketCount.toString(),
};
return {
status: "VALID",
totalFlatWidthRequiredCm: Math.round(totalFlatWidthRequiredCm * 100) / 100,
dropsCount,
cutLengthPerDropCm: Math.round(cutLengthPerDropCm * 100) / 100,
totalLinearMetersRequired,
patternRepeatWasteMeters,
totalMaterialYieldPercentage,
cutList,
hardwareSummary: {
gliderCount,
bracketCount,
endCapCount,
},
workshopAttributes,
};
}Hardware component derivation: gliders, brackets, and kerf compensation
A complete Bill of Materials does not stop at primary materials; it accounts for mounting hardware and machining tolerances.
For wave fold drapery, glider hooks are spaced at fixed intervals along carrier cords (typically 60mm or 80mm). Crucially, wave fold headings require an exact even number of gliders so that both leading and trailing pleats fold toward the glass rather than protruding into the living room.
For woodworking and metal configurators, cutting physical materials destroys raw stock through blade thickness (kerf). A carbide saw blade removes approximately 3.2mm of timber per pass. The BOM engine must add kerf compensation to raw material allocations to prevent short lengths.
Platform boundaries: line item property limits and ERP payload structures
Deploying an automated cut-sheet engine requires strict adherence to Shopify platform constraints:
To prevent confusing customers with internal workshop data, all manufacturing cut-sheet attributes must be prefixed with an underscore (`_cut_sheet_drops`, `_cut_sheet_length_cm`). Shopify automatically hides underscore-prefixed properties from customer-facing interfaces while preserving them on Admin API order payloads.
Serialise discrete scalar values rather than embedding massive nested JSON strings inside line item properties to keep cart mutation payloads fast and lightweight under concurrent traffic.
Because Shopify core has no native concept of raw materials or cut lists, external ERP or WMS inventory systems must decrement raw roll inventory via order creation webhooks (`orders/create`).
When this needs an engineer
You do not need a custom Bill of Materials engine if you sell standard-sized home decor with fixed variant dimensions, or if your products are manufactured off-site by third-party print-on-demand drop-shippers who handle their own production nesting.
You need an engineer when your products are made-to-measure: when raw material consumption depends on continuous mathematical formulas rather than static variant counts; when fabric pattern repeats or timber grain matching dictate cutting schedules; when multi-part hardware kits must be dynamically bundled based on customer measurements; and when you need to bridge storefront configurator inputs directly into factory cutting tables, CNC machines, or ERP bills of materials without human transcription errors.
Explore our engineering work in product configurator development, inspect our configurators case study collection, or examine the bespoke made-to-measure implementation in the Vorhang Schweiz case study.
Send us your product options, material roll dimensions, and workshop cutting rules. We will design a clean, automated Bill of Materials engine that eliminates cutting scrap and delivers machine-ready cut-sheets directly from Shopify orders.
