| name | furniture-design-sketchup |
| description | Use when designing, sketching, or modeling furniture using the SketchUp MCP server. Trigger when user mentions SketchUp, 3D modeling furniture, loft beds, shelves, cabinets, stairs, desks, or any woodworking project that needs visualization. Also use when creating cut lists, shop drawings, or validating furniture designs before building. |
Furniture Design with SketchUp MCP
Design and produce build-ready plans for furniture using SketchUp via MCP (eval_ruby). Two key challenges: the pushpull inversion bug corrupts geometry ~50% of the time, and scenes silently fail to capture state. Verify everything visually — never trust that SketchUp did what you asked.
Architecture
Claude Code → stdio → sketchup-mcp (Python) → TCP :9876 → SketchUp Ruby Extension
Before Starting Any Design
Check these BEFORE modeling (see references/materials-and-tools.md):
- What tools does the builder have? → Constrains joint selection
- What's the finish? → Affects joint visibility and material choice
- Indoor or outdoor? → Affects species, joints, and fasteners
- Project archetype? → Sets defaults (see
references/project-archetypes.md)
Then select plan tier (see references/plan-tiers.md):
- Simple shelf/box → Tier 1
- Furniture → Tier 2
- Complex/modular → Tier 3
Design Phases
- Concept — Identify archetype, select joints (see
references/joint-selection.md). Block out volumes with create_component_box. Get proportions right first. Use ACTUAL lumber dimensions (not nominal).
- Detail — Real lumber dimensions. Convert Groups → Components. Apply materials + tags. Follow archetype tag conventions.
- Joinery — Apply joints per selection guide. Prefer MCP tools (
safe_cut_dado, create_mortise_tenon, etc.) for simple operations; use WW.* via eval_ruby for complex multi-joint scripts. Always verify bounds after every operation.
- Shop Drawings — Generate plan artifacts per tier. Scenes for Layout,
generate_cutlist for cut lists. Verify with verify_scenes.
Critical: The Pushpull Inversion Bug
face.pushpull(distance) direction depends on face normal winding order — unpredictable. Affects boxes, dados, AND bolt holes.
For boxes: Never use pushpull
module MCP_Helpers
def self.make_box(entities, model, name, x, y, z, w, d, h, color_rgb)
grp = entities.add_group
ge = grp.entities
ge.add_face([0,0,0],[w,0,0],[w,d,0],[0,d,0])
ge.add_face([0,0,h],[0,d,h],[w,d,h],[w,0,h])
ge.add_face([0,0,0],[w,0,0],[w,0,h],[0,0,h])
ge.add_face([0,d,0],[0,d,h],[w,d,h],[w,d,0])
ge.add_face([0,0,0],[0,0,h],[0,d,h],[0,d,0])
ge.add_face([w,0,0],[w,d,0],[w,d,h],[w,0,h])
tr = Geom::Transformation.new([x, y, z])
grp.transform!(tr)
grp.name = name
mat = model.materials.add("#{name}_mat_#{rand(9999)}")
mat.color = Sketchup::Color.new(*color_rgb)
mat.alpha = color_rgb[3] if color_rgb.length > 3
grp.material = mat
grp
end
end
For dados: Rebuild the definition (preferred)
Instead of pushpull (which inverts ~50% of the time), rebuild the entire component definition with the dado as explicit geometry. A 6-face solid becomes a 10-face solid with the notch:
defn.entities.clear!
de = defn.entities
w, d, h = 0.75, 22.0, 5.5
dado_z1, dado_z2, dado_depth = 0.5, 1.0, 0.25
de.add_face([0,0,0],[w,0,0],[w,d,0],[0,d,0])
de.add_face([0,0,h],[0,d,h],[w,d,h],[w,0,h])
de.add_face([w,0,0],[w,d,0],[w,d,h],[w,0,h])
de.add_face([0,0,0],[0,d,0],[0,d,dado_z1],[0,0,dado_z1])
de.add_face([0,0,dado_z2],[0,d,dado_z2],[0,d,h],[0,0,h])
de.add_face([0,0,dado_z1],[0,d,dado_z1],[dado_depth,d,dado_z1],[dado_depth,0,dado_z1])
de.add_face([0,0,dado_z2],[dado_depth,0,dado_z2],[dado_depth,d,dado_z2],[0,d,dado_z2])
de.add_face([dado_depth,0,dado_z1],[dado_depth,d,dado_z1],[dado_depth,d,dado_z2],[dado_depth,0,dado_z2])
de.add_face([0,0,0],[w,0,0],[w,0,h],[0,0,h],[0,0,dado_z2],[dado_depth,0,dado_z2],[dado_depth,0,dado_z1],[0,0,dado_z1])
de.add_face([0,d,0],[0,d,dado_z1],[dado_depth,d,dado_z1],[dado_depth,d,dado_z2],[0,d,dado_z2],[0,d,h],[w,d,h],[w,d,0])
This avoids pushpull entirely. The dado is geometrically exact and visible when zoomed in.
For dados/holes: Pushpull then VERIFY bounds (fallback)
Only use pushpull when rebuilding the definition is impractical (e.g., complex existing geometry):
expected_max = 77.0
face = ents.add_face(pts)
face.pushpull(-1.5) if face
if defn.bounds.max.y.to_f > expected_max + 0.01
end
Bolt holes: add_face returns nil
add_circle splits existing faces; add_face returns nil. Use fallback:
edges = ents.add_circle(center, normal, radius, 16)
face = ents.add_face(edges)
face ||= edges.first.faces.select { |f| f.valid? && f.area < 0.5 }.first rescue nil
face.pushpull(-depth) if face
Verify bounds after every hole. See references/sketchup-ruby-api.md for the full drill_hole and trim_extrusion helpers.
OpenCutList Integration
Requirements: Components (not Groups), materials applied, woodworking attributes set.
inst = grp.to_component
inst.definition.name = "RailB"
inst.material = pine
inst.set_attribute("woodworking", "species", "Pine")
inst.set_attribute("woodworking", "nominal_size", "2x12")
Fold repeated parts (e.g., 15 slats → "BedSlat x15"): all instances must share one ComponentDefinition. Keep one definition, erase others, re-place as instances of the kept definition.
Programmatic cut list:
worker = Ladb::OpenCutList::CutlistGenerateWorker.new(
auto_orient: true, smart_material: true, part_folding: true
)
cutlist = worker.run
cutlist.groups.each { |g| g.parts.each { |p| "#{p.count}x #{p.name} | #{p.cutting_length}" } }
Verification Discipline
SketchUp's Ruby API silently produces wrong results. The geometry is fully queryable — use programmatic verification as the primary check and screenshots as visual confirmation.
Level 1: Programmatic Verification (after every operation)
The model state is queryable through eval_ruby. Always verify programmatically before moving on:
After pushpull (dados, holes, mortises):
defn = inst.definition
bb = defn.bounds
issues = []
issues << "X: #{bb.min.x.to_f.round(2)}..#{bb.max.x.to_f.round(2)}" if bb.max.x.to_f > expected_x + 0.01
issues << "Y: #{bb.min.y.to_f.round(2)}..#{bb.max.y.to_f.round(2)}" if bb.max.y.to_f > expected_y + 0.01
face_count = defn.entities.select { |e| e.is_a?(Sketchup::Face) }.count
issues << "face_count=#{face_count} (expected >6)" if face_count <= 6
After scene creation:
page = m.pages[idx]
cam = page.camera
checks = []
checks << "perspective=#{cam.perspective?}"
checks << "height=#{cam.height.to_f.round(1)}"
m.pages.selected_page = page
visible = m.layers.select { |l| l.visible? && l.name != "Layer0" }.map(&:name)
checks << "visible=[#{visible.join(',')}]"
Batch verification after all joinery:
expected = {
"PostBC" => {x: [0, 3.5], y: [0, 3.5], z: [0, 71.25]},
"RailB" => {x: [0, 1.5], y: [0, 77], z: [0, 11.25]},
}
issues = []
expected.each do |name, dims|
inst = entities.find { |e| e.is_a?(Sketchup::ComponentInstance) && e.definition.name == name }
bb = inst.definition.bounds
dims.each do |axis, range|
val = axis == :x ? [bb.min.x, bb.max.x] : axis == :y ? [bb.min.y, bb.max.y] : [bb.min.z, bb.max.z]
issues << "#{name} #{axis} #{val[0].to_f.round(2)}..#{val[1].to_f.round(2)}" if (val[0].to_f - range[0]).abs > 0.01 || (val[1].to_f - range[1]).abs > 0.01
end
end
issues.empty? ? "All clean" : issues.join("\n")
Level 2: Visual Verification (after scenes, after joinery batches)
Screenshots confirm what programmatic checks can't — camera framing, visual clarity of joints, overall composition. Take screenshots at two checkpoints:
After each scene: Activate with ShowTransition = false, take screenshot, Read the PNG:
m.options["PageOptions"]["ShowTransition"] = false
m.pages.selected_page = page
path = File.join(Dir.tmpdir, "verify_#{page.name.gsub(' ','_')}.png")
m.active_view.write_image(path, 1920, 1080, true)
When reviewing the screenshot, check:
- Correct parts visible (no duplicates, no tag bleed)
- Camera framing (subject centered, not cropped)
- Joinery clearly visible (dados read as pockets, holes read as holes)
- Orthographic scenes show no perspective convergence
After all scenes — transition test: Cycle through every scene and verify layer visibility programmatically. The critical failure: tags created after scenes were saved bleed into all views.
Level 3: Annotated Screenshots (for joint details)
For joint detail scenes, add temporary dimensions before the screenshot to make the drawing self-documenting:
d1 = ents.add_dimension_linear([0,0,60], [0,0,71.25], [5,0,0])
d2 = ents.add_dimension_linear([2.0,0,65], [3.5,0,65], [0,0,5])
m.active_view.write_image(path, 1920, 1080, true)
This produces screenshots that a builder (or an LLM reviewing the work) can interpret without needing to read the model file.
Scenes for Layout
Critical pattern — set state BEFORE adding page:
m.layers.each { |l| l.visible = visible_tags.include?(l.name) }
cam = Sketchup::Camera.new
cam.set(eye, target, up)
cam.perspective = false; cam.height = 95
m.active_view.camera = cam
page = m.pages.add("Front Elevation")
page.use_camera = true
m.options["PageOptions"]["ShowTransition"] = false
m.pages.selected_page = page
path = File.join(Dir.tmpdir, "verify_#{page.name.gsub(' ','_')}.png")
m.active_view.write_image(path, 1920, 1080, true)
Update existing: page.update(1 | 32) (1=camera, 32=layers).
Exploded views: Scenes don't capture entity positions. Duplicate geometry to an "Exploded" tag at Z offsets, show only that tag in the exploded scene.
Standard Scene Set
| Type | Camera | Shows |
|---|
| Assembly Overview | Perspective | All parts |
| Elevations (3-4) | Orthographic | All parts, different angles |
| Plan View | Ortho top-down | All parts |
| Section Cuts | Ortho + section plane | Joint details |
| Post/Rail Details | Perspective close-up | Joinery close-ups |
| Assembly Stages | Shared perspective | Progressive tag visibility |
| Exploded | Perspective | Exploded tag only |
Standard Plywood Sizes (Lowe's)
Designs MUST use standard available sizes. Common mistake: specifying 3/8" plywood — most stores don't stock it in hardwood species.
| Thickness | Actual | Available Species | Typical Use |
|---|
| 1/4" | 0.25" | Maple, birch, oak, lauan | Back panels, drawer bottoms (light duty) |
| 1/2" | 0.47-0.50" | Maple, birch, oak | Drawer bottoms (captured in dados), panels |
| 3/4" | 0.73" | Maple, birch, oak | Cabinet carcasses, structural panels |
Before specifying plywood in a design, verify the thickness is available in the desired species at the builder's store.
Cabinet/Dresser Archetype
Standard construction for face-frame cabinets with drawers — the most common beginner furniture project.
Structure:
- 3/4" plywood sides, top, bottom (the carcass)
- 1/4" or 1/2" plywood back panel (keeps it square)
- 1x2 pine face frame (stiles + rails, pocket-holed)
- Drawer boxes: 1x6 pine sides with dadoed plywood bottoms
- Overlay drawer fronts (hardwood for contrast)
Key principles:
- Freestanding is simpler than built-in (no mounting to surrounding structure)
- Side-mount drawer slides need 3/4" plywood or solid wood — 3/8" is too thin for slide screws
- The back panel is the squaring tool — measure diagonals, nail it when square
- Overlay fronts hide imprecision in the face frame openings
- Pocket holes face INSIDE the cabinet (hidden when drawers installed)
Modeling order:
- Carcass (sides, top, bottom) as shared components
- Back panel
- Face frame (stiles + rails)
- Individual drawer box pieces (sides, front/back, bottom — NOT solid blocks)
- Drawer fronts (overlay)
- Tags: assign all to "Drawers" or similar
Cut planning: Verify all pieces fit on standard 4×8 plywood sheets before finalizing dimensions. Draw the cutting layout.
Component Detail Level
For shop drawings and exploded views, components must be modeled at the individual piece level, not as simplified blocks.
| Wrong | Right |
|---|
| Drawer box as 1 solid block | 4 sides + 1 bottom = 5 components |
| Face frame as 1 piece | 2 stiles + N rails = N+2 components |
| Cabinet as 1 box | 2 sides + top + bottom + back = 5 components |
Solid blocks are fine for concept phase (blocking out volumes). But before creating shop drawings, exploded views, or cut lists, every piece that gets cut separately must be a separate component.
Exploded View Technique
Scenes don't capture entity positions. Duplicate all parts to an "Exploded" tag at offset positions:
offsets = {
"Side" => -> (e) { e.bounds.min.x < center_x ? [-spread, 0, 0] : [spread, 0, 0] },
"TopBot" => -> (e) { e.bounds.min.z > mid_z ? [0, 0, spread*2] : [0, 0, -spread] },
"Back" => -> (e) { [0, -spread*1.5, 0] },
"Front" => -> (e) { [0, spread*2.5, 0] },
"Drawer" => -> (e) { [0, spread*1.5, spread*0.3] },
"FF" => -> (e) { [0, spread*1.5, 0] },
}
parts.each do |e|
type = offsets.keys.find { |k| e.definition.name.include?(k) }
ox, oy, oz = offsets[type]&.call(e) || [0, 0, 0]
new_inst = entities.add_instance(e.definition,
Geom::Transformation.new([e.transformation.origin.x + ox, ...]))
new_inst.layer = exploded_tag
new_inst.material = e.material
end
Then create a scene showing only the Exploded tag. The assembled view shows the original components (Drawers tag), the exploded view shows the duplicates (Exploded tag).
Tags
| Tag | Contents |
|---|
| Posts | Structural posts/legs |
| FrameRails | Side rails, end rails |
| Stretchers | Cross-bracing |
| Slats, Ledgers | Platform components |
| Railing | Guard rails, guard posts |
| Ladder | Ladder + hardware |
| Drawers | Cabinet/dresser components |
| Room | Context geometry (hide for shop drawings) |
| Exploded | Duplicate geometry for exploded view |
Common Mistakes
| Mistake | Fix |
|---|
| Pushpull for boxes | MCP_Helpers.make_box — always |
| Not verifying bounds after pushpull | Check definition bounds after EVERY cut |
add_face nil for holes | edges.first.faces.select { f.area < 0.5 } |
| Scene camera/layers not saved | Set state BEFORE pages.add() |
| Not screenshotting each scene | Take screenshot + Read it after EVERY scene |
| Assuming scene transitions work | Cycle through all scenes with ShowTransition=false, verify each |
| Exploded tag bleeds into other scenes | Create Exploded tag BEFORE scenes, or update all scenes after |
| Exploded view reverts | Duplicate geometry on Exploded tag |
| Joint details not visible | Check camera angle shows dado depth, bolt holes, connections |
| Parts don't fold in OpenCutList | Share one ComponentDefinition |
| Definition name collision | Delete/rename old def before creating new |
| Groups for parts | Components required for cut lists |
| Joinery before sizing | Finalize dimensions first |
| Drawer box as solid block | Model individual sides/front/back/bottom for shop drawings |
| Specifying 3/8" plywood | Not stocked at most stores — use 1/4", 1/2", or 3/4" |
| 3/8" ply for slide mounting panels | Too thin — slides need 3/4" ply or solid wood |
| Building into existing structure | Consider freestanding first — simpler, removable, build in garage |
| Dados via pushpull | Rebuild definition with explicit notch geometry instead |
References
| File | Contents |
|---|
references/joint-selection.md | Joint decision framework, lookup tables, anti-patterns, bail-out rules |
references/plan-tiers.md | Tier 1/2/3 artifact checklists, project-to-tier mapping |
references/materials-and-tools.md | Lumber sizes, plywood gotchas, tool tiers, species guide |
references/project-archetypes.md | 6 archetypes with default parts, joints, tags, modeling order |
references/sketchup-ruby-api.md | Ruby API details, helpers, component/transformation patterns |