Craftsman Meets Code: Building Furniture with Parametric Design and LLMs
I broke three pieces of walnut last winter trying to build a bookshelf.
I broke three pieces of walnut last winter trying to build a bookshelf.
Not because I don't know how to build bookshelves — I've been doing woodworking as a hobby for fifteen years, ever since I moved to Alaska and needed furniture that didn't involve a six-week shipping wait from the Lower 48. I broke those pieces because I was trying to fit shelves into an alcove in my cabin that isn't square, isn't level, and has walls that vary by almost two inches across an eight-foot span. Log cabins are charming. They are not geometrically cooperative.
After the third piece of walnut hit the scrap pile, I sat down at my workstation and thought: I write software for a living. I have access to AI models that can reason about spatial relationships. Why am I doing this with a tape measure and mental math?
That's how I ended up building a parametric furniture design system that uses LLMs to generate cut lists, joinery specifications, and assembly instructions from natural language descriptions and a handful of measurements. It's one of the more satisfying things I've built in the last couple of years — partly because it actually saved me wood, and partly because it sits right at the intersection of two skills I care about.
What Is Parametric Design (And Why Should You Care)
Parametric design is the idea that instead of drawing a fixed object, you define an object in terms of parameters and relationships. A bookshelf isn't "42 inches wide" — it's "the width of the alcove minus a half-inch clearance on each side." Change the alcove width, and the entire design updates automatically.
This has been a standard approach in industrial design and architecture for decades. Tools like Grasshopper, OpenSCAD, and FreeCAD's parametric workbench all support it. But traditional parametric design tools have a steep learning curve and assume you think in terms of geometric constraints and solver systems. Most woodworkers — even technically minded ones — don't naturally think that way.
What they do think in terms of is descriptions: "I need a bookshelf that fits this space, with adjustable shelves, and I want to use through-tenon joinery because it looks good and I have the tools for it." That's a natural language specification. And that's exactly the kind of input an LLM can work with.
The System Architecture
The tool has three layers:
┌────────────────────────┐
│ Natural Language Input │ "Build me a bookshelf for an alcove
│ + Measurements │ 62.5" wide, walnut, through-tenons"
└───────────┬────────────┘
▼
┌────────────────────────┐
│ LLM Design Engine │ Generates parametric model as JSON
│ (GPT-4o / Claude) │ with dimensions, joinery, materials
└───────────┬────────────┘
▼
┌────────────────────────┐
│ Output Generator │ Cut list, OpenSCAD model,
│ (Node.js) │ assembly instructions, cost estimate
└────────────────────────┘
The LLM doesn't generate G-code or direct machine instructions. It generates a structured parametric model — essentially a JSON document that describes every component of the piece, how they connect, and the mathematical relationships between their dimensions. Then deterministic code turns that model into practical outputs.
Defining the Parametric Model
The core data structure is a furniture model with parameters, components, joints, and constraints:
var furnitureSchema = {
parameters: {
// User-provided measurements
alcove_width: { type: "measurement", unit: "inches" },
alcove_height: { type: "measurement", unit: "inches" },
alcove_depth: { type: "measurement", unit: "inches" },
// Material parameters
stock_thickness: { type: "measurement", unit: "inches", default: 0.75 },
// Design parameters
shelf_count: { type: "integer", default: 4 },
clearance: { type: "measurement", unit: "inches", default: 0.25 }
},
computed: {
// Derived dimensions — these are the parametric relationships
total_width: "alcove_width - (2 * clearance)",
shelf_width: "total_width - (2 * stock_thickness)",
shelf_spacing: "(alcove_height - ((shelf_count + 1) * stock_thickness)) / shelf_count"
},
components: [], // Populated by LLM
joints: [], // Populated by LLM
hardware: [] // Populated by LLM
};
The key design decision is that the LLM fills in the components, joints, and hardware arrays based on the natural language description, but the mathematical relationships in computed are validated deterministically. The LLM can suggest that shelf_width = total_width - (2 * stock_thickness), but the actual arithmetic happens in JavaScript, not in the model's token prediction.
This matters because LLMs are notoriously unreliable at arithmetic. I don't want a model telling me a shelf is 24.375 inches when it should be 24.5. The model defines relationships; the code evaluates them.
The LLM Design Prompt
Getting the LLM to produce good furniture designs took serious prompt engineering. Early attempts gave me designs that were structurally unsound, dimensionally impossible, or just ugly. The breakthrough was providing it with woodworking domain knowledge:
var designPrompt = [
"You are an expert furniture designer and woodworker with deep knowledge of",
"joinery, wood properties, and structural engineering for furniture.",
"",
"Design a piece of furniture based on the following specification:",
"",
"SPECIFICATION:",
"{userDescription}",
"",
"MEASUREMENTS:",
"{measurements}",
"",
"CONSTRAINTS:",
"- All dimensions must be expressed as formulas referencing parameters, not fixed numbers",
"- Wood grain direction matters: long grain for strength, end grain only for decorative purposes",
"- Account for wood movement: leave gaps for cross-grain expansion (approximately",
" 1/8\" per 12\" of width for hardwoods in variable humidity environments)",
"- Joinery must be structurally appropriate for the loads involved",
"- Standard lumber dimensions: 4/4 stock mills to 3/4\", 6/4 to 1.25\", 8/4 to 1.75\"",
"- Shelf spans over 36\" without support will sag under book loads",
"",
"JOINERY REFERENCE:",
"- Through mortise and tenon: strong, visible, requires chisel work",
"- Blind mortise and tenon: strong, hidden, requires drill press or router",
"- Dovetails: excellent for corners, resists pulling apart",
"- Dado/rabbet: good for shelves, simple to cut with router or table saw",
"- Dowels: moderate strength, easy alignment with jig",
"- Pocket screws: fast, hidden, adequate for non-heirloom pieces",
"",
"Return a JSON object following this schema:",
JSON.stringify(furnitureSchema, null, 2),
"",
"Each component needs: id, name, material, length, width, thickness (as formulas),",
"grain_direction, quantity, and notes.",
"",
"Each joint needs: type, component_a, component_b, position, dimensions (as formulas),",
"and notes about cutting technique."
].join("\n");
The constraints section is crucial. Without explicit guidance about wood movement, the LLM will design a panel glue-up that's going to crack in an Alaskan winter when the wood shrinks. Without the shelf span limit, it'll happily spec a 48-inch unsupported shelf that'll bow under a row of hardcover textbooks.
Evaluating the Parametric Expressions
Once the LLM returns a design, I need to evaluate all the parametric expressions into actual numbers. This is the part that has to be bulletproof — bad math means wasted lumber:
function evaluateDesign(design, userParams) {
// Merge user parameters with defaults
var params = {};
Object.keys(design.parameters).forEach(function(key) {
var param = design.parameters[key];
params[key] = userParams[key] !== undefined ? userParams[key] : param.default;
if (params[key] === undefined) {
throw new Error("Missing required parameter: " + key);
}
});
// Evaluate computed values
var computed = {};
Object.keys(design.computed).forEach(function(key) {
computed[key] = evaluateExpression(design.computed[key], params);
params[key] = computed[key]; // Make available for subsequent expressions
});
// Evaluate component dimensions
var components = design.components.map(function(comp) {
return {
id: comp.id,
name: comp.name,
material: comp.material,
length: evaluateExpression(comp.length, params),
width: evaluateExpression(comp.width, params),
thickness: evaluateExpression(comp.thickness, params),
grain_direction: comp.grain_direction,
quantity: comp.quantity,
notes: comp.notes
};
});
// Validate structural integrity
var warnings = validateDesign(components, design.joints);
return {
parameters: params,
computed: computed,
components: components,
joints: design.joints,
warnings: warnings,
totalBoardFeet: calculateBoardFeet(components)
};
}
function evaluateExpression(expr, params) {
if (typeof expr === "number") return expr;
// Replace parameter names with values
var evaluated = expr;
var sortedKeys = Object.keys(params).sort(function(a, b) {
return b.length - a.length; // Replace longer names first to avoid partial matches
});
sortedKeys.forEach(function(key) {
var regex = new RegExp("\\b" + key + "\\b", "g");
evaluated = evaluated.replace(regex, params[key]);
});
// Evaluate the mathematical expression
try {
var result = Function("return (" + evaluated + ")")();
if (typeof result !== "number" || isNaN(result)) {
throw new Error("Expression did not evaluate to a number: " + expr);
}
return Math.round(result * 1000) / 1000; // Round to thousandths
} catch (e) {
throw new Error("Failed to evaluate expression '" + expr + "': " + e.message);
}
}
function calculateBoardFeet(components) {
var total = 0;
components.forEach(function(comp) {
// Board feet = (thickness × width × length) / 144, with waste factor
var bf = (comp.thickness * comp.width * comp.length) / 144;
bf *= 1.2; // 20% waste factor for cuts, defects, mistakes
total += bf * comp.quantity;
});
return Math.ceil(total * 10) / 10;
}
The 20% waste factor in calculateBoardFeet isn't arbitrary — it's what I've learned from experience. You lose wood to saw kerfs, defects you discover when you start milling, pieces that split during mortising, and the occasional measurement error. If you buy exactly the lumber the math says you need, you'll be making a second trip to the lumber yard.
Generating the Cut List
The cut list is the most practically useful output. It's what you actually take to the shop:
function generateCutList(evaluatedDesign) {
var components = evaluatedDesign.components.slice();
// Sort by material, then by length (longest first — cut those from your best boards)
components.sort(function(a, b) {
if (a.material !== b.material) return a.material.localeCompare(b.material);
return b.length - a.length;
});
var cutList = [];
var currentMaterial = null;
components.forEach(function(comp) {
if (comp.material !== currentMaterial) {
currentMaterial = comp.material;
cutList.push({ type: "header", material: currentMaterial });
}
for (var i = 0; i < comp.quantity; i++) {
cutList.push({
type: "cut",
name: comp.name + (comp.quantity > 1 ? " (" + (i+1) + "/" + comp.quantity + ")" : ""),
length: formatDimension(comp.length),
width: formatDimension(comp.width),
thickness: formatDimension(comp.thickness),
grain: comp.grain_direction,
notes: comp.notes
});
}
});
return cutList;
}
function formatDimension(inches) {
var whole = Math.floor(inches);
var frac = inches - whole;
// Convert to nearest 1/16
var sixteenths = Math.round(frac * 16);
if (sixteenths === 16) { whole++; sixteenths = 0; }
if (sixteenths === 0) return whole + '"';
// Simplify fraction
var num = sixteenths;
var den = 16;
while (num % 2 === 0) { num /= 2; den /= 2; }
return whole + '-' + num + '/' + den + '"';
}
The formatDimension function converts decimal inches to fractional inches — because nobody in a woodshop measures 2.4375 inches. That's 2-7/16". If you've ever tried to find 0.4375 on a tape measure, you understand why this conversion matters.
Generating an OpenSCAD Model
For visual verification before I cut any wood, I generate an OpenSCAD script that renders a 3D model of the piece:
function generateOpenSCAD(evaluatedDesign) {
var scad = "// Generated by Parametric Furniture Designer\n";
scad += "// " + new Date().toISOString() + "\n\n";
scad += "$fn = 32;\n\n";
// Material colors
scad += 'walnut = [0.35, 0.2, 0.1];\n';
scad += 'oak = [0.6, 0.45, 0.25];\n';
scad += 'maple = [0.85, 0.75, 0.55];\n';
scad += 'cherry = [0.55, 0.27, 0.15];\n\n';
evaluatedDesign.components.forEach(function(comp) {
var colorName = comp.material.toLowerCase();
if (["walnut", "oak", "maple", "cherry"].indexOf(colorName) === -1) {
colorName = "oak"; // default
}
scad += "// " + comp.name + "\n";
scad += "color(" + colorName + ") ";
if (comp.position) {
scad += "translate([" + comp.position.x + ", " +
comp.position.y + ", " + comp.position.z + "]) ";
}
if (comp.rotation) {
scad += "rotate([" + comp.rotation.x + ", " +
comp.rotation.y + ", " + comp.rotation.z + "]) ";
}
scad += "cube([" + comp.length + ", " + comp.thickness + ", " + comp.width + "]);\n\n";
});
return scad;
}
Opening the generated .scad file in OpenSCAD gives me an instant visual check. I can rotate the model, verify proportions, and spot obvious problems before I touch a single board. Last month this saved me from building a desk where the LLM had the drawer rail positioned in a way that would have blocked the drawers from opening. It looked fine in the JSON. It looked obviously wrong in 3D.
Real Results: The Alcove Bookshelf
Back to the bookshelf that started all of this. I fed the system:
"Bookshelf to fit an alcove. Alcove is 62.5 inches wide at the top and 64.25 inches wide at the bottom. Height is 78 inches. Depth is 11.5 inches. Walnut, through-tenon joinery. Five shelves. Bottom shelf taller for large books."
The system handled the non-square alcove by making the sides slightly tapered — wider at the bottom to match. It spec'd the through-tenons at 3/8" x 1-1/2", which is exactly what I'd have chosen manually. It accounted for wood movement by making the shelves 1/8" narrower than the dado slots. It put the bottom shelf at 14 inches instead of the 10-inch spacing for the upper shelves.
Total board feet required: 28.4 (including waste factor). The cut list had 17 pieces. I bought 30 board feet of 4/4 walnut, milled it all in one session, and every piece fit. No trips back to the lumber yard. No pieces in the scrap pile.
The bookshelf is currently holding about 200 pounds of books, and it hasn't moved a sixteenth of an inch.
Where the LLM Struggles
It's not perfect. Three failure modes I've encountered:
Complex joinery interactions. When joints intersect — like a through-tenon that meets a dado at the same point — the LLM sometimes generates physically impossible geometry. Two pieces of wood can't occupy the same space. I added a collision detection step that checks for overlapping joints and flags them.
Material-specific behavior. The LLM knows that wood moves, but it doesn't really understand that cherry moves differently than white oak, or that quartersawn boards move differently than flatsawn. I've had to add a materials database with specific expansion coefficients.
Aesthetic proportions. Engineering correctness doesn't guarantee visual appeal. The LLM will sometimes generate designs that are structurally sound but look weird — leg proportions that feel heavy, aprons that are too deep, stretchers placed at awkward heights. This is the hardest problem to solve because "looks right" is subjective and culturally embedded. I've started including example images of good furniture proportions in the prompt, which helps, but it's still the weakest part of the system.
Lessons for AI-Augmented Craft
The most important thing I've learned building this system is that AI works best in craft domains when it handles the computational parts and leaves the judgment to the human. The LLM is excellent at generating structurally sound parametric models from natural language descriptions. It's good at remembering a hundred woodworking rules simultaneously. It's terrible at knowing whether a piece of furniture will look beautiful in a room.
The second lesson is that the output format matters more than the model quality. A mediocre design in a perfect cut list is infinitely more useful than a brilliant design in a wall of unstructured text. When I switched from asking the LLM to "describe a bookshelf design" to asking it to fill in a strict JSON schema, the results improved dramatically — not because the model got smarter, but because the structure forced completeness and precision.
The third lesson is specific to woodworking but applies broadly: domain expertise can't be skipped. If I didn't know that through-tenons need a specific grain orientation to resist splitting, I wouldn't have known to put that in the prompt. If I didn't understand board-foot calculations and waste factors, I'd trust the model's material estimates and come up short. The AI amplifies my woodworking knowledge. It doesn't replace it.
I've since used the system to build a standing desk, two nightstands, and a tool cabinet. Every build went smoother than my pre-AI projects. Not because the designs were revolutionary — they were pretty standard furniture — but because the cut lists were accurate, the joinery was well-specified, and I could verify everything in 3D before committing a board to the saw.
If you're a maker who also writes code, I'd encourage you to build something like this for your own craft. It doesn't have to be woodworking. The same approach works for metalwork, sewing patterns, leather goods — anything where you're translating a design intent into precise material specifications. The LLM handles the computational tedium. You handle the craft.
And if you're a software engineer who doesn't do any physical making — start. There's something deeply grounding about building a thing you can touch after spending all day building things you can't. The bookshelf I'm looking at right now doesn't need a deployment pipeline, doesn't have a sprint review, and will never throw an unhandled exception at 3 AM. It just holds books. Sometimes that's exactly what you need.
Shane Larson is a software engineer and the founder of Grizzly Peak Software. He builds software from a cabin in Caswell Lakes, Alaska, where the furniture has to fit log walls that refuse to be square. His book on training large language models is available on Amazon.